WEB-APP/ Optimization/ Build fix/ Hotkey edit mode/ Log rotation/ Form a11y/ E2E non-blocking
This commit is contained in:
+154
-21
@@ -1,14 +1,16 @@
|
||||
import hashlib
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from fastapi import Depends, HTTPException, Header, Query, Request
|
||||
from fastapi import Depends, HTTPException, Header, Query, Request, Response
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from audit import set_api_actor
|
||||
from database import async_session_maker, identities as idb
|
||||
from database.models import Admin
|
||||
from database.access.resolution import ResolvedActor, resolve_actor_from_identity
|
||||
from database.models import Admin, Identity
|
||||
|
||||
|
||||
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
@@ -25,6 +27,24 @@ def hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
async def bind_identity_actor(
|
||||
request: Request | None,
|
||||
session: AsyncSession,
|
||||
identity: Identity,
|
||||
) -> ResolvedActor:
|
||||
actor = await resolve_actor_from_identity(session, identity)
|
||||
set_api_actor(request, identity_id=actor.identity_id, tg_id=actor.telegram_chat_id)
|
||||
if request is not None:
|
||||
request.state.actor = actor
|
||||
return actor
|
||||
|
||||
|
||||
def get_request_actor(request: Request | None) -> ResolvedActor | None:
|
||||
if request is None:
|
||||
return None
|
||||
return getattr(request.state, "actor", None)
|
||||
|
||||
|
||||
async def verify_admin_token(
|
||||
admin_id: int = Query(..., alias="tg_id"),
|
||||
token: str = Header(..., alias="X-Token"),
|
||||
@@ -40,50 +60,146 @@ async def verify_admin_token(
|
||||
return admin
|
||||
|
||||
|
||||
AUTH_COOKIE_NAME = "auth_token"
|
||||
|
||||
|
||||
IS_ADMIN_COOKIE_NAME = "is_admin"
|
||||
|
||||
|
||||
AUTH_COOKIE_MAX_AGE_SECONDS = 30 * 24 * 60 * 60
|
||||
|
||||
|
||||
def _is_secure_request(request: Request | None) -> bool:
|
||||
if request is None:
|
||||
return False
|
||||
if request.url.scheme == "https":
|
||||
return True
|
||||
forwarded_proto = request.headers.get("x-forwarded-proto", "").lower()
|
||||
return forwarded_proto == "https"
|
||||
|
||||
|
||||
def set_auth_cookie(response: Response, token: str, request: Request | None = None) -> None:
|
||||
"""Устанавливает HttpOnly cookie с auth-токеном на ответ. Используется во всех login-ручках."""
|
||||
response.set_cookie(
|
||||
key=AUTH_COOKIE_NAME,
|
||||
value=token,
|
||||
max_age=AUTH_COOKIE_MAX_AGE_SECONDS,
|
||||
path="/",
|
||||
httponly=True,
|
||||
secure=_is_secure_request(request),
|
||||
samesite="lax",
|
||||
)
|
||||
|
||||
|
||||
def clear_auth_cookie(response: Response, request: Request | None = None) -> None:
|
||||
"""Удаляет auth cookie на стороне браузера."""
|
||||
response.delete_cookie(
|
||||
key=AUTH_COOKIE_NAME,
|
||||
path="/",
|
||||
httponly=True,
|
||||
secure=_is_secure_request(request),
|
||||
samesite="lax",
|
||||
)
|
||||
|
||||
clear_is_admin_cookie(response, request)
|
||||
|
||||
|
||||
def set_is_admin_cookie(response: Response, identity: Identity, request: Request | None = None) -> None:
|
||||
"""Ставит/гасит `is_admin` cookie в зависимости от текущей identity."""
|
||||
if getattr(identity, "is_admin", False):
|
||||
response.set_cookie(
|
||||
key=IS_ADMIN_COOKIE_NAME,
|
||||
value="1",
|
||||
max_age=AUTH_COOKIE_MAX_AGE_SECONDS,
|
||||
path="/",
|
||||
httponly=True,
|
||||
secure=_is_secure_request(request),
|
||||
samesite="lax",
|
||||
)
|
||||
else:
|
||||
clear_is_admin_cookie(response, request)
|
||||
|
||||
|
||||
def clear_is_admin_cookie(response: Response, request: Request | None = None) -> None:
|
||||
response.delete_cookie(
|
||||
key=IS_ADMIN_COOKIE_NAME,
|
||||
path="/",
|
||||
httponly=True,
|
||||
secure=_is_secure_request(request),
|
||||
samesite="lax",
|
||||
)
|
||||
|
||||
|
||||
def _read_auth_cookie(request: Request | None) -> str | None:
|
||||
if request is None:
|
||||
return None
|
||||
raw = request.cookies.get(AUTH_COOKIE_NAME)
|
||||
if not raw:
|
||||
return None
|
||||
raw = raw.strip()
|
||||
return raw or None
|
||||
|
||||
|
||||
async def _identity_from_cookie(session: AsyncSession, request: Request | None) -> Identity | None:
|
||||
token = _read_auth_cookie(request)
|
||||
if not token:
|
||||
return None
|
||||
token_hash = hash_token(token)
|
||||
identity = await idb.get_identity_by_token_hash(session, token_hash)
|
||||
if identity is None:
|
||||
return None
|
||||
if idb._is_token_expired(identity):
|
||||
return None
|
||||
return identity
|
||||
|
||||
|
||||
async def verify_identity_token(
|
||||
x_identity_id: str = Header(..., alias="X-Identity-Id"),
|
||||
token: str = Header(..., alias="X-Token"),
|
||||
request: Request = None,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Проверяет пару identity_id + token; возвращает Identity. Для использования в API v2."""
|
||||
identity = await idb.verify_identity_token(session, x_identity_id, token)
|
||||
if not identity:
|
||||
"""Проверяет токен из HttpOnly cookie `auth_token`; возвращает Identity."""
|
||||
identity = await _identity_from_cookie(session, request)
|
||||
if identity is None:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
set_api_actor(request, identity_id=identity.id, tg_id=identity.tg_id)
|
||||
await bind_identity_actor(request, session, identity)
|
||||
return identity
|
||||
|
||||
|
||||
async def verify_identity_admin(
|
||||
x_identity_id: str = Header(..., alias="X-Identity-Id"),
|
||||
token: str = Header(..., alias="X-Token"),
|
||||
request: Request = None,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Проверяет identity + token и что identity.is_admin; для админских ручек v2."""
|
||||
identity = await idb.verify_identity_token(session, x_identity_id, token)
|
||||
if not identity:
|
||||
"""Проверяет токен из cookie и что identity.is_admin; для админских ручек v2."""
|
||||
identity = await _identity_from_cookie(session, request)
|
||||
if identity is None:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
if not identity.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
set_api_actor(request, identity_id=identity.id, tg_id=identity.tg_id)
|
||||
await bind_identity_actor(request, session, identity)
|
||||
return identity
|
||||
|
||||
|
||||
async def verify_identity_admin_short(
|
||||
x_identity_id: str = Header(..., alias="X-Identity-Id"),
|
||||
token: str = Header(..., alias="X-Token"),
|
||||
request: Request = None,
|
||||
request: Request,
|
||||
):
|
||||
"""Проверка админа с короткой сессией (для broadcast и др.), чтобы не держать соединение с БД."""
|
||||
identity = None
|
||||
actor = None
|
||||
async with async_session_maker() as session:
|
||||
identity = await idb.verify_identity_token(session, x_identity_id, token)
|
||||
identity = await _identity_from_cookie(session, request)
|
||||
if identity:
|
||||
actor = await resolve_actor_from_identity(session, identity)
|
||||
await session.commit()
|
||||
if not identity:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
if not identity.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
set_api_actor(request, identity_id=identity.id, tg_id=identity.tg_id)
|
||||
if actor is not None:
|
||||
set_api_actor(request, identity_id=identity.id, tg_id=actor.telegram_chat_id)
|
||||
if request is not None:
|
||||
request.state.actor = actor
|
||||
else:
|
||||
set_api_actor(request, identity_id=identity.id, tg_id=identity.tg_id)
|
||||
return identity
|
||||
|
||||
|
||||
@@ -102,3 +218,20 @@ async def verify_admin_token_short(
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
set_api_actor(request, tg_id=admin.tg_id)
|
||||
return admin
|
||||
|
||||
|
||||
def validate_redirect_url(url: str, base_url: str) -> str:
|
||||
"""Validate redirect URL is same-origin or relative. Returns safe URL or base_url fallback."""
|
||||
url = url.strip()
|
||||
if not url:
|
||||
return base_url
|
||||
if url.startswith("/"):
|
||||
return url
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
base_parsed = urlparse(base_url)
|
||||
if parsed.scheme in ("http", "https") and parsed.netloc == base_parsed.netloc:
|
||||
return url
|
||||
except Exception:
|
||||
pass
|
||||
return base_url
|
||||
|
||||
+24
-5
@@ -14,7 +14,8 @@ from logger import logger
|
||||
if API_VERSION == 1:
|
||||
from api.v1 import router as api_router, VERSION as API_DOC_VERSION
|
||||
else:
|
||||
from api.v2 import router as api_router, VERSION as API_DOC_VERSION
|
||||
from api.v2 import VERSION as API_DOC_VERSION
|
||||
from api.v2.router import router as api_router
|
||||
|
||||
app = FastAPI(
|
||||
title=f"SoloBot API (Alpha) — API v{API_DOC_VERSION}",
|
||||
@@ -25,15 +26,28 @@ app = FastAPI(
|
||||
openapi_url="/api/openapi.json",
|
||||
)
|
||||
|
||||
_cors_origins = API_CORS_ORIGINS if API_CORS_ORIGINS != ["*"] else API_CORS_ORIGINS
|
||||
_cors_credentials = API_CORS_ORIGINS != ["*"]
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=API_CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
allow_origins=_cors_origins,
|
||||
allow_credentials=_cors_credentials,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
|
||||
allow_headers=["X-Identity-Id", "X-Token", "Content-Type", "Authorization"],
|
||||
)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def security_headers_middleware(request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||
response.headers.setdefault("X-Frame-Options", "DENY")
|
||||
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
response.headers.setdefault("X-XSS-Protection", "1; mode=block")
|
||||
return response
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def api_access_log_middleware(request: Request, call_next):
|
||||
context = ensure_api_context(request)
|
||||
@@ -86,6 +100,11 @@ async def api_access_log_middleware(request: Request, call_next):
|
||||
return response
|
||||
|
||||
|
||||
@app.get("/api/health", include_in_schema=False)
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
app.include_router(api_router)
|
||||
|
||||
_web_uploads_dir = "static/web_uploads"
|
||||
|
||||
+58
-11
@@ -1,12 +1,22 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
|
||||
from sqlalchemy import inspect as sa_inspect
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
||||
|
||||
from api.depends import get_session, verify_admin_token
|
||||
from database.models import Admin
|
||||
from database.access.resolution import resolve_user_optional
|
||||
from handlers.texts import get_site_gift_link, get_telegram_gift_link
|
||||
|
||||
|
||||
def _apply_user_relationship_loader(model: type, stmt):
|
||||
if model.__name__ in ("ManualBan", "BlockedUser", "TemporaryData"):
|
||||
return stmt.options(selectinload(model.user))
|
||||
return stmt
|
||||
|
||||
|
||||
def cast_identifier_type(field: InstrumentedAttribute, value: int | str):
|
||||
@@ -19,6 +29,21 @@ def cast_identifier_type(field: InstrumentedAttribute, value: int | str):
|
||||
def normalize_outgoing_object(obj: object) -> None:
|
||||
if hasattr(obj, "vless") and getattr(obj, "vless") is None:
|
||||
setattr(obj, "vless", False)
|
||||
cls_name = type(obj).__name__
|
||||
if cls_name == "Gift":
|
||||
gift_id = getattr(obj, "gift_id", None)
|
||||
if gift_id:
|
||||
setattr(obj, "telegram_gift_link", get_telegram_gift_link(gift_id))
|
||||
setattr(obj, "site_gift_link", get_site_gift_link(gift_id))
|
||||
if cls_name in ("ManualBan", "BlockedUser", "TemporaryData"):
|
||||
insp = sa_inspect(obj)
|
||||
stored = getattr(obj, "tg_id", None)
|
||||
if "user" in insp.unloaded:
|
||||
setattr(obj, "tg_id", stored)
|
||||
return
|
||||
rel = getattr(obj, "user", None)
|
||||
rel_tg = getattr(rel, "tg_id", None) if rel is not None else None
|
||||
setattr(obj, "tg_id", stored if stored is not None else rel_tg)
|
||||
|
||||
|
||||
def to_schema(schema_response: type, obj: object):
|
||||
@@ -35,10 +60,20 @@ def generate_crud_router(
|
||||
identifier_field: str = "tg_id",
|
||||
parameter_name: str = "tg_id",
|
||||
extra_get_by_email: bool = False,
|
||||
telegram_path_to_user_id: bool = False,
|
||||
enabled_methods: list[str] = ("get_all", "get_one", "get_by_email", "create", "update", "delete"),
|
||||
) -> APIRouter:
|
||||
router = APIRouter()
|
||||
|
||||
async def _path_filter(session: AsyncSession, value: int | str):
|
||||
if telegram_path_to_user_id:
|
||||
u = await resolve_user_optional(session, int(value))
|
||||
if u is None:
|
||||
return None
|
||||
return getattr(model, "user_id"), u.id
|
||||
field = getattr(model, identifier_field)
|
||||
return field, cast_identifier_type(field, value)
|
||||
|
||||
if "get_all" in enabled_methods:
|
||||
|
||||
@router.get("/", response_model=list[schema_response])
|
||||
@@ -46,7 +81,7 @@ def generate_crud_router(
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(select(model))
|
||||
result = await session.execute(_apply_user_relationship_loader(model, select(model)))
|
||||
items = result.scalars().all()
|
||||
for item in items:
|
||||
normalize_outgoing_object(item)
|
||||
@@ -74,9 +109,13 @@ def generate_crud_router(
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
field = getattr(model, identifier_field)
|
||||
casted = cast_identifier_type(field, value)
|
||||
result = await session.execute(select(model).where(field == casted))
|
||||
resolved = await _path_filter(session, value)
|
||||
if resolved is None:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
field, casted = resolved
|
||||
result = await session.execute(
|
||||
_apply_user_relationship_loader(model, select(model).where(field == casted))
|
||||
)
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
@@ -90,9 +129,13 @@ def generate_crud_router(
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
field = getattr(model, identifier_field)
|
||||
casted = cast_identifier_type(field, value)
|
||||
result = await session.execute(select(model).where(field == casted))
|
||||
resolved = await _path_filter(session, value)
|
||||
if resolved is None:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
field, casted = resolved
|
||||
result = await session.execute(
|
||||
_apply_user_relationship_loader(model, select(model).where(field == casted))
|
||||
)
|
||||
objs = result.scalars().all()
|
||||
if not objs:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
@@ -127,8 +170,10 @@ def generate_crud_router(
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
field = getattr(model, identifier_field)
|
||||
casted = cast_identifier_type(field, value)
|
||||
resolved = await _path_filter(session, value)
|
||||
if resolved is None:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
field, casted = resolved
|
||||
result = await session.execute(select(model).where(field == casted))
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
@@ -150,8 +195,10 @@ def generate_crud_router(
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
field = getattr(model, identifier_field)
|
||||
casted = cast_identifier_type(field, value)
|
||||
resolved = await _path_filter(session, value)
|
||||
if resolved is None:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
field, casted = resolved
|
||||
result = await session.execute(select(model).where(field == casted))
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
|
||||
@@ -6,6 +6,7 @@ from api.depends import get_session, verify_admin_token
|
||||
from api.v1.routes.base_crud import generate_crud_router
|
||||
from api.v1.schemas import GiftBase, GiftResponse, GiftUpdate, GiftUsageResponse
|
||||
from database.models import Admin, Gift, GiftUsage
|
||||
from database.access.resolution import resolve_user_optional
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
@@ -29,7 +30,10 @@ async def get_gifts_by_tg_id(
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(select(Gift).where(Gift.sender_tg_id == tg_id))
|
||||
u = await resolve_user_optional(session, tg_id)
|
||||
if u is None:
|
||||
raise HTTPException(status_code=404, detail="Gifts not found")
|
||||
result = await session.execute(select(Gift).where(Gift.sender_user_id == u.id))
|
||||
gifts = result.scalars().all()
|
||||
if not gifts:
|
||||
raise HTTPException(status_code=404, detail="Gifts not found")
|
||||
|
||||
@@ -8,7 +8,8 @@ from api.depends import get_session, verify_admin_token
|
||||
from api.v1.routes.base_crud import generate_crud_router
|
||||
from api.v1.schemas.keys import KeyBase, KeyCreateRequest, KeyResponse, KeyUpdate
|
||||
from database.models import Admin, Key, Tariff
|
||||
from handlers.keys.operations import create_key_on_cluster, delete_key_from_cluster, renew_key_in_cluster
|
||||
from database.access.resolution import resolve_user_optional
|
||||
from services.operations import create_key_on_cluster, delete_key_from_cluster, renew_key_in_cluster
|
||||
from logger import logger
|
||||
|
||||
|
||||
@@ -63,7 +64,10 @@ async def get_router_keys_by_tg_id(
|
||||
if not tariff_ids:
|
||||
return []
|
||||
|
||||
keys_result = await session.execute(select(Key).where(Key.tg_id == tg_id, Key.tariff_id.in_(tariff_ids)))
|
||||
u = await resolve_user_optional(session, tg_id)
|
||||
if u is None:
|
||||
return []
|
||||
keys_result = await session.execute(select(Key).where(Key.user_id == u.id, Key.tariff_id.in_(tariff_ids)))
|
||||
keys = keys_result.scalars().all()
|
||||
return keys
|
||||
|
||||
|
||||
@@ -208,7 +208,7 @@ async def restore_trials(
|
||||
update(User)
|
||||
.where(
|
||||
User.trial == 1,
|
||||
~exists(select(Key.tg_id).where(Key.tg_id == User.tg_id)),
|
||||
~exists(select(Key.user_id).where(Key.user_id == User.id)),
|
||||
)
|
||||
.values(trial=0)
|
||||
)
|
||||
|
||||
+16
-5
@@ -13,6 +13,7 @@ from api.v1.schemas import (
|
||||
TrackingSourceResponse,
|
||||
)
|
||||
from database import get_tracking_source_stats
|
||||
from database.access.resolution import resolve_user_optional
|
||||
from database.models import (
|
||||
Admin,
|
||||
BlockedUser,
|
||||
@@ -47,7 +48,10 @@ async def get_payments_by_tg_id(
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(select(Payment).where(Payment.tg_id == tg_id))
|
||||
u = await resolve_user_optional(session, tg_id)
|
||||
if u is None:
|
||||
raise HTTPException(status_code=404, detail="Payments not found")
|
||||
result = await session.execute(select(Payment).where(Payment.user_id == u.id))
|
||||
payments = result.scalars().all()
|
||||
if not payments:
|
||||
raise HTTPException(status_code=404, detail="Payments not found")
|
||||
@@ -60,7 +64,8 @@ router.include_router(
|
||||
schema_response=NotificationResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="tg_id",
|
||||
identifier_field="user_id",
|
||||
telegram_path_to_user_id=True,
|
||||
enabled_methods=["get_all", "get_one", "delete"],
|
||||
),
|
||||
prefix="/notifications",
|
||||
@@ -75,7 +80,9 @@ router.include_router(
|
||||
schema_response=ManualBanResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="tg_id",
|
||||
identifier_field="user_id",
|
||||
parameter_name="tg_id",
|
||||
telegram_path_to_user_id=True,
|
||||
enabled_methods=["get_all", "get_one", "delete"],
|
||||
),
|
||||
prefix="/manual-bans",
|
||||
@@ -89,7 +96,9 @@ router.include_router(
|
||||
schema_response=BlockedUserResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="tg_id",
|
||||
identifier_field="user_id",
|
||||
parameter_name="tg_id",
|
||||
telegram_path_to_user_id=True,
|
||||
enabled_methods=["get_all", "get_one", "delete"],
|
||||
),
|
||||
prefix="/blocked-users",
|
||||
@@ -103,7 +112,9 @@ router.include_router(
|
||||
schema_response=TemporaryDataResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="tg_id",
|
||||
identifier_field="user_id",
|
||||
parameter_name="tg_id",
|
||||
telegram_path_to_user_id=True,
|
||||
enabled_methods=["get_all", "get_one", "delete"],
|
||||
),
|
||||
prefix="/temporary-data",
|
||||
|
||||
@@ -6,6 +6,7 @@ from api.depends import get_session, verify_admin_token
|
||||
from api.v1.routes.base_crud import generate_crud_router
|
||||
from api.v1.schemas import ReferralResponse
|
||||
from database.models import Admin, Referral
|
||||
from database.access.resolution import resolve_user_optional
|
||||
|
||||
|
||||
router = generate_crud_router(
|
||||
@@ -13,7 +14,9 @@ router = generate_crud_router(
|
||||
schema_response=ReferralResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="referrer_tg_id",
|
||||
identifier_field="referrer_user_id",
|
||||
parameter_name="referrer_tg_id",
|
||||
telegram_path_to_user_id=True,
|
||||
enabled_methods=["get_all", "get_one", "get_all_by_field"],
|
||||
)
|
||||
|
||||
@@ -25,8 +28,15 @@ async def delete_one_referral(
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
ru_ref = await resolve_user_optional(session, referrer_tg_id)
|
||||
rd_ref = await resolve_user_optional(session, referred_tg_id)
|
||||
if ru_ref is None or rd_ref is None:
|
||||
raise HTTPException(status_code=404, detail="Referral not found")
|
||||
result = await session.execute(
|
||||
select(Referral).where(Referral.referrer_tg_id == referrer_tg_id, Referral.referred_tg_id == referred_tg_id)
|
||||
select(Referral).where(
|
||||
Referral.referrer_user_id == ru_ref.id,
|
||||
Referral.referred_user_id == rd_ref.id,
|
||||
)
|
||||
)
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
|
||||
@@ -9,7 +9,8 @@ from api.v1.routes.base_crud import generate_crud_router
|
||||
from api.v1.schemas.users import UserBase, UserResponse, UserUpdate
|
||||
from database import async_session_maker, delete_user_data, get_servers
|
||||
from database.models import Key, User
|
||||
from handlers.keys.operations import delete_key_from_cluster
|
||||
from database.access.resolution import resolve_user_optional
|
||||
from services.operations import delete_key_from_cluster
|
||||
from logger import logger
|
||||
|
||||
|
||||
@@ -30,7 +31,10 @@ async def delete_user(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
result = await session.execute(select(Key.email, Key.client_id).where(Key.tg_id == tg_id))
|
||||
u = await resolve_user_optional(session, tg_id)
|
||||
if u is None:
|
||||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||||
result = await session.execute(select(Key.email, Key.client_id).where(Key.user_id == u.id))
|
||||
key_records = result.all()
|
||||
|
||||
async with async_session_maker() as s:
|
||||
|
||||
@@ -4,11 +4,13 @@ from pydantic import BaseModel
|
||||
|
||||
|
||||
class GiftBase(BaseModel):
|
||||
sender_tg_id: int
|
||||
recipient_tg_id: int | None = None
|
||||
sender_user_id: int
|
||||
recipient_user_id: int | None = None
|
||||
selected_months: int | None = None
|
||||
expiry_time: datetime
|
||||
gift_link: str
|
||||
telegram_gift_link: str | None = None
|
||||
site_gift_link: str | None = None
|
||||
is_used: bool = False
|
||||
is_unlimited: bool | None = False
|
||||
max_usages: int | None = None
|
||||
@@ -33,10 +35,12 @@ class GiftUsageResponse(BaseModel):
|
||||
|
||||
|
||||
class GiftUpdate(BaseModel):
|
||||
recipient_tg_id: int | None = None
|
||||
recipient_user_id: int | None = None
|
||||
selected_months: int | None = None
|
||||
expiry_time: datetime | None = None
|
||||
gift_link: str | None = None
|
||||
telegram_gift_link: str | None = None
|
||||
site_gift_link: str | None = None
|
||||
is_used: bool | None = None
|
||||
is_unlimited: bool | None = None
|
||||
max_usages: int | None = None
|
||||
|
||||
@@ -2,8 +2,9 @@ from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class KeyBase(BaseModel):
|
||||
tg_id: int
|
||||
user_id: int
|
||||
client_id: str
|
||||
tg_id: int | None = None
|
||||
email: str | None = None
|
||||
created_at: int | None = None
|
||||
expiry_time: int
|
||||
|
||||
+14
-8
@@ -4,7 +4,8 @@ from pydantic import BaseModel
|
||||
|
||||
|
||||
class PaymentBase(BaseModel):
|
||||
tg_id: int
|
||||
user_id: int
|
||||
tg_id: int | None = None
|
||||
amount: float
|
||||
payment_system: str
|
||||
status: str
|
||||
@@ -19,8 +20,8 @@ class PaymentResponse(PaymentBase):
|
||||
|
||||
|
||||
class ReferralResponse(BaseModel):
|
||||
referred_tg_id: int
|
||||
referrer_tg_id: int
|
||||
referred_user_id: int
|
||||
referrer_user_id: int
|
||||
reward_issued: bool = False
|
||||
|
||||
class Config:
|
||||
@@ -37,11 +38,13 @@ class NotificationResponse(BaseModel):
|
||||
|
||||
|
||||
class GiftBase(BaseModel):
|
||||
sender_tg_id: int
|
||||
recipient_tg_id: int | None = None
|
||||
sender_user_id: int
|
||||
recipient_user_id: int | None = None
|
||||
selected_months: int
|
||||
expiry_time: datetime
|
||||
gift_link: str
|
||||
telegram_gift_link: str | None = None
|
||||
site_gift_link: str | None = None
|
||||
is_used: bool = False
|
||||
is_unlimited: bool = False
|
||||
max_usages: int | None = None
|
||||
@@ -66,7 +69,8 @@ class GiftUsageResponse(BaseModel):
|
||||
|
||||
|
||||
class ManualBanResponse(BaseModel):
|
||||
tg_id: int
|
||||
user_id: int
|
||||
tg_id: int | None = None
|
||||
banned_at: datetime
|
||||
reason: str
|
||||
banned_by: int
|
||||
@@ -77,7 +81,8 @@ class ManualBanResponse(BaseModel):
|
||||
|
||||
|
||||
class TemporaryDataResponse(BaseModel):
|
||||
tg_id: int
|
||||
user_id: int
|
||||
tg_id: int | None = None
|
||||
state: str
|
||||
data: dict
|
||||
updated_at: datetime
|
||||
@@ -87,7 +92,8 @@ class TemporaryDataResponse(BaseModel):
|
||||
|
||||
|
||||
class BlockedUserResponse(BaseModel):
|
||||
tg_id: int
|
||||
user_id: int
|
||||
tg_id: int | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@@ -2,8 +2,8 @@ from pydantic import BaseModel
|
||||
|
||||
|
||||
class ReferralResponse(BaseModel):
|
||||
referred_tg_id: int
|
||||
referrer_tg_id: int
|
||||
referred_user_id: int
|
||||
referrer_user_id: int
|
||||
reward_issued: bool = False
|
||||
|
||||
class Config:
|
||||
|
||||
+7
-3
@@ -1,5 +1,9 @@
|
||||
from api.v2.router import router
|
||||
|
||||
VERSION = "2.0.0"
|
||||
|
||||
__all__ = ("router", "VERSION")
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
if name == "router":
|
||||
from api.v2.router import router
|
||||
return router
|
||||
raise AttributeError(name)
|
||||
|
||||
+40
-12
@@ -6,7 +6,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
||||
|
||||
from api.depends import get_session, verify_identity_admin
|
||||
from api.v1.routes.base_crud import cast_identifier_type, normalize_outgoing_object, to_schema
|
||||
from api.v1.routes.base_crud import (
|
||||
_apply_user_relationship_loader,
|
||||
cast_identifier_type,
|
||||
normalize_outgoing_object,
|
||||
to_schema,
|
||||
)
|
||||
from database.access.resolution import resolve_user_optional
|
||||
|
||||
|
||||
def generate_crud_router(
|
||||
@@ -18,10 +24,20 @@ def generate_crud_router(
|
||||
identifier_field: str = "tg_id",
|
||||
parameter_name: str = "tg_id",
|
||||
extra_get_by_email: bool = False,
|
||||
telegram_path_to_user_id: bool = False,
|
||||
enabled_methods: list[str] = ("get_all", "get_one", "get_by_email", "create", "update", "delete"),
|
||||
) -> APIRouter:
|
||||
router = APIRouter()
|
||||
|
||||
async def _path_filter(session: AsyncSession, value: int | str):
|
||||
if telegram_path_to_user_id:
|
||||
u = await resolve_user_optional(session, int(value))
|
||||
if u is None:
|
||||
return None
|
||||
return getattr(model, "user_id"), u.id
|
||||
field = getattr(model, identifier_field)
|
||||
return field, cast_identifier_type(field, value)
|
||||
|
||||
if "get_all" in enabled_methods:
|
||||
|
||||
@router.get("/", response_model=list[schema_response])
|
||||
@@ -29,7 +45,7 @@ def generate_crud_router(
|
||||
identity=Depends(verify_identity_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(select(model))
|
||||
result = await session.execute(_apply_user_relationship_loader(model, select(model)))
|
||||
items = result.scalars().all()
|
||||
for item in items:
|
||||
normalize_outgoing_object(item)
|
||||
@@ -57,9 +73,13 @@ def generate_crud_router(
|
||||
identity=Depends(verify_identity_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
field = getattr(model, identifier_field)
|
||||
casted = cast_identifier_type(field, value)
|
||||
result = await session.execute(select(model).where(field == casted))
|
||||
resolved = await _path_filter(session, value)
|
||||
if resolved is None:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
field, casted = resolved
|
||||
result = await session.execute(
|
||||
_apply_user_relationship_loader(model, select(model).where(field == casted))
|
||||
)
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
@@ -73,9 +93,13 @@ def generate_crud_router(
|
||||
identity=Depends(verify_identity_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
field = getattr(model, identifier_field)
|
||||
casted = cast_identifier_type(field, value)
|
||||
result = await session.execute(select(model).where(field == casted))
|
||||
resolved = await _path_filter(session, value)
|
||||
if resolved is None:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
field, casted = resolved
|
||||
result = await session.execute(
|
||||
_apply_user_relationship_loader(model, select(model).where(field == casted))
|
||||
)
|
||||
objs = result.scalars().all()
|
||||
if not objs:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
@@ -110,8 +134,10 @@ def generate_crud_router(
|
||||
identity=Depends(verify_identity_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
field = getattr(model, identifier_field)
|
||||
casted = cast_identifier_type(field, value)
|
||||
resolved = await _path_filter(session, value)
|
||||
if resolved is None:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
field, casted = resolved
|
||||
result = await session.execute(select(model).where(field == casted))
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
@@ -131,8 +157,10 @@ def generate_crud_router(
|
||||
identity=Depends(verify_identity_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
field = getattr(model, identifier_field)
|
||||
casted = cast_identifier_type(field, value)
|
||||
resolved = await _path_filter(session, value)
|
||||
if resolved is None:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
field, casted = resolved
|
||||
result = await session.execute(select(model).where(field == casted))
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
|
||||
+8
-2
@@ -18,6 +18,8 @@ from api.v2.routes import (
|
||||
payment_links,
|
||||
identities,
|
||||
web,
|
||||
flows,
|
||||
notifications,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
@@ -25,10 +27,12 @@ router = APIRouter()
|
||||
router.include_router(root_router)
|
||||
router.include_router(auth.router, prefix="/api")
|
||||
router.include_router(users.router, prefix="/api/users", tags=["Users"])
|
||||
router.include_router(keys.router, prefix="/api/keys", tags=["Keys"])
|
||||
router.include_router(keys.user_router, prefix="/api/keys", tags=["Keys"])
|
||||
router.include_router(keys.router, prefix="/api/admin/keys", tags=["AdminKeys"])
|
||||
router.include_router(coupons.router, prefix="/api/coupons", tags=["Coupons"])
|
||||
router.include_router(servers.router, prefix="/api/servers", tags=["Servers"])
|
||||
router.include_router(tariffs.public_router, prefix="/api/tariffs", tags=["Tariffs"])
|
||||
router.include_router(tariffs.user_tariff_router, prefix="/api/tariffs", tags=["Tariffs"])
|
||||
router.include_router(tariffs.router, prefix="/api/tariffs", tags=["Tariffs"])
|
||||
router.include_router(gifts.router, prefix="/api/gifts", tags=["Gifts"])
|
||||
router.include_router(referrals.router, prefix="/api/referrals", tags=["Referrals"])
|
||||
@@ -39,4 +43,6 @@ router.include_router(misc.router, prefix="/api")
|
||||
router.include_router(modules.router, prefix="/api")
|
||||
router.include_router(management.router, prefix="/api/management", tags=["Management"])
|
||||
router.include_router(settings.router, prefix="/api/settings", tags=["Settings"])
|
||||
router.include_router(web.router, prefix="", tags=["Web"])
|
||||
router.include_router(web.router, prefix="", tags=["Web"])
|
||||
router.include_router(flows.router, prefix="/api", tags=["Flows"])
|
||||
router.include_router(notifications.router, prefix="/api", tags=["Notifications"])
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
from api.v2.routes.root import router as root_router
|
||||
|
||||
__all__ = ("root_router",)
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
if name == "root_router":
|
||||
from api.v2.routes.root import router
|
||||
|
||||
return router
|
||||
raise AttributeError(name)
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from audit import set_api_actor
|
||||
from api.depends import get_session, verify_identity_token
|
||||
from api.v2.schemas.identities import (
|
||||
IdentityResponse,
|
||||
LinkTelegramRequest,
|
||||
LoginByCodeRequest,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
LoginTelegramRequest,
|
||||
RegisterByEmailRequest,
|
||||
RegisterResponse,
|
||||
SendLoginCodeRequest,
|
||||
)
|
||||
from config import API_TOKEN_TTL_DAYS, API_TOKEN
|
||||
from database import identities as idb
|
||||
from utils.telegram_login import verify_telegram_login
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["Auth"])
|
||||
TOKEN_TTL_HINT = "бессрочно" if API_TOKEN_TTL_DAYS is None else f"{API_TOKEN_TTL_DAYS} дн."
|
||||
TELEGRAM_LOGIN_MAX_AGE = 86400
|
||||
|
||||
|
||||
@router.post("/register", response_model=RegisterResponse)
|
||||
async def register_by_email(
|
||||
body: RegisterByEmailRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
(
|
||||
"""Регистрация по почте и паролю: создаётся идентичность, выдаётся токен. Срок действия токена: """
|
||||
+ TOKEN_TTL_HINT
|
||||
+ "."
|
||||
)
|
||||
email = body.email.strip().lower()
|
||||
if not email:
|
||||
raise HTTPException(status_code=400, detail="Email обязателен")
|
||||
if not body.password or len(body.password) < 8:
|
||||
raise HTTPException(status_code=400, detail="Пароль минимум 8 символов")
|
||||
existing = await idb.get_identity_by_email(session, email)
|
||||
if existing:
|
||||
raise HTTPException(status_code=409, detail="Идентичность с таким email уже существует")
|
||||
identity, token = await idb.create_identity_with_token(session, email=email, password=body.password)
|
||||
set_api_actor(request, identity_id=identity.id, tg_id=identity.tg_id)
|
||||
return RegisterResponse(identity_id=identity.id, token=token)
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
async def login(
|
||||
body: LoginRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Вход по email и паролю. Возвращает identity_id и новый токен. Срок действия токена: """ + TOKEN_TTL_HINT + "."
|
||||
email = body.email.strip().lower()
|
||||
if not email:
|
||||
raise HTTPException(status_code=400, detail="Email обязателен")
|
||||
result = await idb.login_by_email(session, email, body.password)
|
||||
if not result:
|
||||
raise HTTPException(status_code=401, detail="Неверный email или пароль")
|
||||
identity, token = result
|
||||
set_api_actor(request, identity_id=identity.id, tg_id=identity.tg_id)
|
||||
return LoginResponse(identity_id=identity.id, token=token)
|
||||
|
||||
|
||||
_LOGIN_CODES: dict[str, tuple[str, float]] = {}
|
||||
_LOGIN_CODE_TTL = 600.0
|
||||
|
||||
|
||||
def _clean_login_codes() -> None:
|
||||
import time
|
||||
now = time.time()
|
||||
for k in list(_LOGIN_CODES):
|
||||
if now - _LOGIN_CODES[k][1] > _LOGIN_CODE_TTL:
|
||||
del _LOGIN_CODES[k]
|
||||
|
||||
|
||||
@router.post("/send-login-code")
|
||||
async def send_login_code(
|
||||
body: SendLoginCodeRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Отправить код входа на email. Код хранится на сервере 10 мин (для демо — без реальной отправки письма)."""
|
||||
_clean_login_codes()
|
||||
email = body.email.strip().lower()
|
||||
if not email:
|
||||
raise HTTPException(status_code=400, detail="Email обязателен")
|
||||
identity = await idb.get_identity_by_email(session, email)
|
||||
if not identity:
|
||||
raise HTTPException(status_code=404, detail="Аккаунт с таким email не найден")
|
||||
import secrets
|
||||
import time
|
||||
code = "".join(secrets.choice("0123456789") for _ in range(6))
|
||||
_LOGIN_CODES[email] = (code, time.time())
|
||||
return {"ok": True, "message": "Код отправлен на почту"}
|
||||
|
||||
|
||||
@router.post("/login-by-code", response_model=LoginResponse)
|
||||
async def login_by_code(
|
||||
body: LoginByCodeRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Вход по email и коду из письма."""
|
||||
_clean_login_codes()
|
||||
email = body.email.strip().lower()
|
||||
if not email or not body.code or not body.code.strip():
|
||||
raise HTTPException(status_code=400, detail="Email и код обязательны")
|
||||
stored = _LOGIN_CODES.get(email)
|
||||
if not stored:
|
||||
raise HTTPException(status_code=400, detail="Код не найден или истёк. Запросите новый.")
|
||||
code_value, _ = stored
|
||||
if body.code.strip() != code_value:
|
||||
raise HTTPException(status_code=401, detail="Неверный код")
|
||||
del _LOGIN_CODES[email]
|
||||
identity = await idb.get_identity_by_email(session, email)
|
||||
if not identity:
|
||||
raise HTTPException(status_code=401, detail="Аккаунт не найден")
|
||||
token = await idb.issue_token_for_identity(session, identity)
|
||||
set_api_actor(request, identity_id=identity.id, tg_id=identity.tg_id)
|
||||
return LoginResponse(identity_id=identity.id, token=token)
|
||||
|
||||
|
||||
@router.post("/login-telegram", response_model=LoginResponse)
|
||||
async def login_telegram(
|
||||
body: LoginTelegramRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
(
|
||||
"""Вход через Telegram Login Widget (кнопка на сайте). По tg_id находим или создаём Identity, выдаём токен. Срок действия токена: """
|
||||
+ TOKEN_TTL_HINT
|
||||
+ "."
|
||||
)
|
||||
payload = body.model_dump(mode="json")
|
||||
if not verify_telegram_login(payload, API_TOKEN, max_age_seconds=TELEGRAM_LOGIN_MAX_AGE):
|
||||
raise HTTPException(status_code=401, detail="Неверная подпись или устаревшие данные от Telegram")
|
||||
identity = await idb.get_or_create_identity_for_tg(session, body.id)
|
||||
token = await idb.issue_token_for_identity(session, identity)
|
||||
set_api_actor(request, identity_id=identity.id, tg_id=identity.tg_id)
|
||||
return LoginResponse(identity_id=identity.id, token=token)
|
||||
|
||||
|
||||
@router.post("/link-telegram", response_model=IdentityResponse)
|
||||
async def link_telegram(
|
||||
body: LinkTelegramRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
"""Привязывает Telegram к текущей идентичности. Требуется подпись от Telegram Login Widget (доказательство владения аккаунтом)."""
|
||||
payload = body.model_dump(mode="json")
|
||||
if not verify_telegram_login(payload, API_TOKEN, max_age_seconds=TELEGRAM_LOGIN_MAX_AGE):
|
||||
raise HTTPException(status_code=401, detail="Неверная подпись или устаревшие данные от Telegram")
|
||||
result = await idb.attach_telegram(session, identity.id, body.id)
|
||||
if not result:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Этот Telegram уже привязан к другой идентичности",
|
||||
)
|
||||
set_api_actor(request, identity_id=result.id, tg_id=result.tg_id)
|
||||
return IdentityResponse.model_validate(result)
|
||||
|
||||
|
||||
@router.get("/me", response_model=IdentityResponse)
|
||||
async def me(
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
"""Текущая идентичность по заголовкам X-Identity-Id и X-Token."""
|
||||
return IdentityResponse.model_validate(identity)
|
||||
@@ -0,0 +1,13 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from api.v2.routes.auth import email_verify, link, password, session, telegram
|
||||
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["Auth"])
|
||||
router.include_router(password.router)
|
||||
router.include_router(telegram.router)
|
||||
router.include_router(link.router)
|
||||
router.include_router(email_verify.router)
|
||||
router.include_router(session.router)
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -0,0 +1,112 @@
|
||||
from fastapi import Request
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import API_TOKEN_TTL_DAYS
|
||||
from logger import logger
|
||||
from utils.referral_codes import encode_partner_code
|
||||
|
||||
|
||||
TOKEN_TTL_HINT = "бессрочно" if API_TOKEN_TTL_DAYS is None else f"{API_TOKEN_TTL_DAYS} дн."
|
||||
TELEGRAM_LOGIN_MAX_AGE = 86400
|
||||
|
||||
_TRUSTED_PROXY_CIDRS: list[str] = []
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
client_host = (request.client.host if request.client else "") or ""
|
||||
forwarded = request.headers.get("x-forwarded-for") or request.headers.get("X-Forwarded-For")
|
||||
if not forwarded:
|
||||
return client_host
|
||||
if not _TRUSTED_PROXY_CIDRS and client_host not in ("127.0.0.1", "::1"):
|
||||
return client_host
|
||||
return forwarded.split(",")[0].strip() or client_host
|
||||
|
||||
|
||||
async def _resolve_partner_snapshot(session: AsyncSession, billing_user_id: int) -> dict[str, object]:
|
||||
partner_feature_enabled = False
|
||||
default_percent = 0.0
|
||||
try:
|
||||
from modules.partner_program import settings as partner_settings
|
||||
|
||||
partner_feature_enabled = True
|
||||
raw_percent = getattr(partner_settings, "PARTNER_BONUS_PERCENTAGES", {}).get(1, 0.0)
|
||||
default_percent = float(raw_percent) * 100.0
|
||||
except Exception:
|
||||
partner_feature_enabled = False
|
||||
default_percent = 0.0
|
||||
payload: dict[str, object] = {
|
||||
"partner_enabled": partner_feature_enabled,
|
||||
"partner_code": "",
|
||||
"partner_balance": 0.0,
|
||||
"partner_percent": default_percent,
|
||||
"partner_percent_custom": False,
|
||||
"partner_referred_total": 0,
|
||||
"partner_payout_method": None,
|
||||
}
|
||||
try:
|
||||
partner_row = (
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT
|
||||
tg_id,
|
||||
COALESCE(partner_balance, 0),
|
||||
partner_percent,
|
||||
COALESCE(partner_percent_custom, false),
|
||||
partner_code,
|
||||
payout_method
|
||||
FROM users
|
||||
WHERE id = :user_id
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"user_id": int(billing_user_id)},
|
||||
)
|
||||
).first()
|
||||
except Exception:
|
||||
partner_row = None
|
||||
if partner_row is None:
|
||||
return payload
|
||||
tg_id = int(partner_row[0]) if partner_row[0] is not None else None
|
||||
balance = float(partner_row[1] or 0.0)
|
||||
percent_raw = partner_row[2]
|
||||
percent_custom = bool(partner_row[3])
|
||||
percent_value = float(percent_raw) if (percent_custom and percent_raw is not None) else float(default_percent)
|
||||
code = str(partner_row[4] or "").strip()
|
||||
if (not code or code.isdigit() or code.startswith("r1_")) and int(billing_user_id) > 0:
|
||||
generated_code = encode_partner_code(int(billing_user_id))
|
||||
code = generated_code
|
||||
try:
|
||||
await session.execute(
|
||||
text("UPDATE users SET partner_code = :code WHERE id = :id"),
|
||||
{"code": generated_code, "id": int(billing_user_id)},
|
||||
)
|
||||
await session.flush()
|
||||
except Exception as e:
|
||||
logger.warning("[Auth] Ошибка сохранения partner_code для billing_user_id={}: {}", billing_user_id, e)
|
||||
payout_method = str(partner_row[5] or "").strip() or None
|
||||
referred_total = 0
|
||||
if tg_id is not None:
|
||||
try:
|
||||
referred_total = int(
|
||||
(
|
||||
await session.execute(
|
||||
text("SELECT COUNT(*) FROM partners WHERE partner_tg_id = :tg_id"),
|
||||
{"tg_id": int(tg_id)},
|
||||
)
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
except Exception:
|
||||
referred_total = 0
|
||||
payload.update({
|
||||
"partner_enabled": bool(partner_feature_enabled or code or referred_total > 0 or balance > 0),
|
||||
"partner_code": code,
|
||||
"partner_balance": balance,
|
||||
"partner_percent": percent_value,
|
||||
"partner_percent_custom": percent_custom,
|
||||
"partner_referred_total": referred_total,
|
||||
"partner_payout_method": payout_method,
|
||||
})
|
||||
return payload
|
||||
@@ -0,0 +1,43 @@
|
||||
"""In-memory rate limit fallback для случаев когда Redis недоступен.
|
||||
|
||||
Используется только когда Redis не ответил — чтобы критичные auth-эндпоинты
|
||||
не теряли защиту при кратковременных Redis-сбоях. Per-process, не шарится
|
||||
между репликами — поэтому на нескольких инстансах лимит будет N*limit.
|
||||
"""
|
||||
|
||||
import time
|
||||
from collections import deque
|
||||
from threading import Lock
|
||||
|
||||
|
||||
_BUCKETS: dict[str, deque[float]] = {}
|
||||
_LOCK = Lock()
|
||||
_MAX_KEYS = 10000
|
||||
|
||||
|
||||
def _prune(bucket: deque[float], window_sec: int) -> None:
|
||||
threshold = time.monotonic() - window_sec
|
||||
while bucket and bucket[0] < threshold:
|
||||
bucket.popleft()
|
||||
|
||||
|
||||
def _evict_if_full() -> None:
|
||||
if len(_BUCKETS) < _MAX_KEYS:
|
||||
return
|
||||
now = time.monotonic()
|
||||
dead = [k for k, b in _BUCKETS.items() if not b or b[-1] < now - 3600]
|
||||
for k in dead:
|
||||
del _BUCKETS[k]
|
||||
if len(_BUCKETS) >= _MAX_KEYS:
|
||||
oldest = min(_BUCKETS.keys(), key=lambda k: _BUCKETS[k][0] if _BUCKETS[k] else 0)
|
||||
del _BUCKETS[oldest]
|
||||
|
||||
|
||||
def check_and_increment(key: str, limit: int, window_sec: int) -> int:
|
||||
"""Возвращает текущее значение счётчика после инкремента. Если >= limit — превышение."""
|
||||
with _LOCK:
|
||||
_evict_if_full()
|
||||
bucket = _BUCKETS.setdefault(key, deque())
|
||||
_prune(bucket, window_sec)
|
||||
bucket.append(time.monotonic())
|
||||
return len(bucket)
|
||||
@@ -0,0 +1,79 @@
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
Field as PydanticField,
|
||||
)
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_identity_token
|
||||
from api.v2.routes.auth._common import _client_ip
|
||||
from mail import send_email_verify_code_email, smtp_configured
|
||||
from utils import web_email_verify_code as verify_util
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class VerifyEmailRequest(BaseModel):
|
||||
code: str = PydanticField(..., min_length=1, max_length=10)
|
||||
|
||||
|
||||
@router.post("/send-verify-code")
|
||||
async def send_email_verify_code(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
"""Отправить код подтверждения email. Требует авторизации."""
|
||||
if not smtp_configured():
|
||||
raise HTTPException(status_code=503, detail="Почтовый сервер не настроен")
|
||||
email = (identity.email or "").strip().lower()
|
||||
if not email:
|
||||
raise HTTPException(status_code=400, detail="Email не привязан к аккаунту")
|
||||
if getattr(identity, "email_verified", False):
|
||||
return {"ok": True, "detail": "Email уже подтверждён"}
|
||||
if not await verify_util.redis_ready():
|
||||
raise HTTPException(status_code=503, detail="Сервис временно недоступен")
|
||||
ip = _client_ip(request)
|
||||
if not await verify_util.try_consume_ip_send_budget(ip):
|
||||
raise HTTPException(status_code=429, detail="Слишком много запросов, попробуйте позже")
|
||||
if not await verify_util.try_consume_email_send_budget(email):
|
||||
raise HTTPException(status_code=429, detail="Слишком много запросов на этот email")
|
||||
if not await verify_util.try_acquire_resend_cooldown(email):
|
||||
raise HTTPException(status_code=429, detail="Подождите минуту перед повторной отправкой")
|
||||
code = f"{secrets.randbelow(900000) + 100000}"
|
||||
await verify_util.store_code(email, code)
|
||||
try:
|
||||
await send_email_verify_code_email(email, code)
|
||||
except Exception:
|
||||
await verify_util.delete_code(email)
|
||||
raise HTTPException(status_code=503, detail="Не удалось отправить письмо")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/verify-email")
|
||||
async def verify_email(
|
||||
body: VerifyEmailRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
"""Подтвердить email по коду."""
|
||||
email = (identity.email or "").strip().lower()
|
||||
if not email:
|
||||
raise HTTPException(status_code=400, detail="Email не привязан к аккаунту")
|
||||
if getattr(identity, "email_verified", False):
|
||||
return {"ok": True, "detail": "Email уже подтверждён"}
|
||||
if not await verify_util.try_consume_verify_budget(email):
|
||||
raise HTTPException(status_code=429, detail="Слишком много попыток, попробуйте позже")
|
||||
if not await verify_util.verify_and_consume_code(email, body.code.strip()):
|
||||
raise HTTPException(status_code=400, detail="Неверный или просроченный код")
|
||||
from sqlalchemy import update
|
||||
|
||||
from database.models import Identity as IdentityModel
|
||||
await session.execute(
|
||||
update(IdentityModel).where(IdentityModel.id == identity.id).values(email_verified=True)
|
||||
)
|
||||
return {"ok": True}
|
||||
@@ -0,0 +1,114 @@
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import (
|
||||
bind_identity_actor,
|
||||
get_session,
|
||||
verify_identity_token,
|
||||
)
|
||||
from api.v2.routes.auth._common import _client_ip
|
||||
from api.v2.schemas.identities import (
|
||||
IdentityResponse,
|
||||
LinkEmailConfirmRequest,
|
||||
LinkEmailSendCodeRequest,
|
||||
)
|
||||
from database import identities as idb
|
||||
from mail import send_email_link_code_email, smtp_configured
|
||||
from utils import web_email_link_code as email_link_code
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/link-email/send-code")
|
||||
async def link_email_send_code(
|
||||
body: LinkEmailSendCodeRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
email_norm = email_link_code.normalize_email(body.email)
|
||||
if not email_norm:
|
||||
raise HTTPException(status_code=400, detail="Укажите корректный email")
|
||||
if identity.email and str(identity.email).strip().lower() == email_norm:
|
||||
raise HTTPException(status_code=409, detail="Этот email уже привязан к аккаунту")
|
||||
if not smtp_configured():
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Отправка кода недоступна: почта не настроена на сервере",
|
||||
)
|
||||
if not await email_link_code.redis_ready():
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Сервис временно недоступен. Попробуйте позже.",
|
||||
)
|
||||
existing = await idb.get_identity_by_email(session, email_norm)
|
||||
if existing and existing.id != identity.id:
|
||||
raise HTTPException(status_code=400, detail="Не удалось привязать email")
|
||||
ip = _client_ip(request)
|
||||
if not await email_link_code.try_consume_ip_budget(ip):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Слишком много запросов с вашего адреса. Попробуйте позже.",
|
||||
)
|
||||
if not await email_link_code.try_consume_email_send_budget(email_norm):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Слишком много запросов для этого адреса. Попробуйте позже.",
|
||||
)
|
||||
if not await email_link_code.try_acquire_cooldown(email_norm):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Код уже отправлен. Подождите перед повторной отправкой.",
|
||||
)
|
||||
code = "".join(secrets.choice("0123456789") for _ in range(6))
|
||||
if not await email_link_code.store_code(email_norm, code):
|
||||
await email_link_code.release_cooldown(email_norm)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Не удалось сохранить код. Попробуйте позже.",
|
||||
)
|
||||
try:
|
||||
await send_email_link_code_email(email_norm, code)
|
||||
except Exception:
|
||||
await email_link_code.release_cooldown(email_norm)
|
||||
await email_link_code.delete_code(email_norm)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Не удалось отправить письмо. Попробуйте позже.",
|
||||
) from None
|
||||
return {"ok": True, "message": "Код подтверждения отправлен на почту"}
|
||||
|
||||
|
||||
@router.post("/link-email/confirm", response_model=IdentityResponse)
|
||||
async def link_email_confirm(
|
||||
body: LinkEmailConfirmRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
email_norm = email_link_code.normalize_email(body.email)
|
||||
if not email_norm or not body.code or not str(body.code).strip():
|
||||
raise HTTPException(status_code=400, detail="Email и код обязательны")
|
||||
if not await email_link_code.redis_ready():
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Сервис временно недоступен. Попробуйте позже.",
|
||||
)
|
||||
if not await email_link_code.try_consume_email_verify_budget(email_norm):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Слишком много попыток. Запросите новый код.",
|
||||
)
|
||||
if not await email_link_code.verify_and_consume_code(email_norm, str(body.code).strip()):
|
||||
raise HTTPException(status_code=401, detail="Неверный код или срок действия истёк")
|
||||
result = await idb.attach_email(session, identity.id, email_norm)
|
||||
if not result:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Этот email уже привязан к другой идентичности",
|
||||
)
|
||||
await bind_identity_actor(request, session, result)
|
||||
return IdentityResponse.model_validate(result)
|
||||
@@ -0,0 +1,389 @@
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import (
|
||||
bind_identity_actor,
|
||||
get_session,
|
||||
set_auth_cookie,
|
||||
set_is_admin_cookie,
|
||||
)
|
||||
from api.v2.routes.auth._common import TOKEN_TTL_HINT, _client_ip
|
||||
from api.v2.schemas.identities import (
|
||||
ConfirmPasswordResetRequest,
|
||||
LoginByCodeRequest,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
RegisterByEmailRequest,
|
||||
RegisterResponse,
|
||||
SendLoginCodeRequest,
|
||||
)
|
||||
from database import (
|
||||
add_referral,
|
||||
get_referral_by_referred_id,
|
||||
identities as idb,
|
||||
)
|
||||
from database.access.resolution import resolve_user_optional
|
||||
from logger import logger
|
||||
from mail import (
|
||||
send_email_verify_code_email,
|
||||
send_login_code_email,
|
||||
send_password_reset_code_email,
|
||||
smtp_configured,
|
||||
)
|
||||
from utils import (
|
||||
web_email_verify_code as email_verify,
|
||||
web_password_reset_code as pwd_reset,
|
||||
)
|
||||
from utils.disposable_emails import is_disposable_email
|
||||
from utils.referral_codes import decode_referral_code
|
||||
from utils.turnstile import turnstile_enabled, verify_turnstile_token
|
||||
from utils.web_login_code import (
|
||||
delete_code,
|
||||
normalize_login_email,
|
||||
redis_ready_for_login_codes,
|
||||
release_resend_cooldown,
|
||||
store_code,
|
||||
try_acquire_resend_cooldown,
|
||||
try_consume_email_send_budget,
|
||||
try_consume_email_verify_budget,
|
||||
try_consume_ip_send_budget,
|
||||
verify_and_consume_code,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_RESET_OK_MESSAGE = {
|
||||
"ok": True,
|
||||
"message": "Если для этого адреса есть аккаунт с паролем, мы отправили код. Проверьте почту.",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/register", response_model=RegisterResponse)
|
||||
async def register_by_email(
|
||||
body: RegisterByEmailRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
(
|
||||
"""Регистрация по почте и паролю: создаётся идентичность, выдаётся токен. Срок действия токена: """
|
||||
+ TOKEN_TTL_HINT
|
||||
+ "."
|
||||
)
|
||||
ip = _client_ip(request)
|
||||
try:
|
||||
from core.redis_cache import cache_incr_checked
|
||||
from api.v2.routes.auth._fallback_limiter import check_and_increment
|
||||
count, redis_ok = await cache_incr_checked(f"register_rate:{ip}", 3600)
|
||||
if not redis_ok:
|
||||
count = check_and_increment(f"register_rate:{ip}", 5, 3600)
|
||||
if count > 5:
|
||||
raise HTTPException(status_code=429, detail="Слишком много регистраций с этого IP. Попробуйте позже.")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
pass
|
||||
if turnstile_enabled():
|
||||
if not await verify_turnstile_token(body.turnstile_token, ip):
|
||||
raise HTTPException(status_code=400, detail="Проверка CAPTCHA не пройдена")
|
||||
email = body.email.strip().lower()
|
||||
if not email:
|
||||
raise HTTPException(status_code=400, detail="Email обязателен")
|
||||
if is_disposable_email(email):
|
||||
raise HTTPException(status_code=400, detail="Одноразовые email-адреса не поддерживаются")
|
||||
if not body.password or len(body.password) < 8:
|
||||
raise HTTPException(status_code=400, detail="Пароль минимум 8 символов")
|
||||
existing = await idb.get_identity_by_email(session, email)
|
||||
if existing:
|
||||
raise HTTPException(status_code=409, detail="Идентичность с таким email уже существует")
|
||||
raw_referral = str(body.referral_code or "").strip()
|
||||
if "/referral/" in raw_referral:
|
||||
raw_referral = raw_referral.split("/referral/", 1)[-1]
|
||||
if "start=referral_" in raw_referral:
|
||||
raw_referral = raw_referral.split("start=referral_", 1)[-1]
|
||||
raw_referral = raw_referral.split("?", 1)[0].split("#", 1)[0].strip()
|
||||
referrer_legacy = decode_referral_code(raw_referral)
|
||||
referrer_user = None
|
||||
if body.referral_code and referrer_legacy is None:
|
||||
raise HTTPException(status_code=400, detail="Код приглашения недействителен")
|
||||
if referrer_legacy is not None:
|
||||
referrer_user = await resolve_user_optional(session, referrer_legacy)
|
||||
if referrer_user is None:
|
||||
raise HTTPException(status_code=400, detail="Код приглашения недействителен")
|
||||
identity, token = await idb.create_identity_with_token(session, email=email, password=body.password)
|
||||
await bind_identity_actor(request, session, identity)
|
||||
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
if referrer_user is not None and not await get_referral_by_referred_id(session, billing_user_id):
|
||||
await add_referral(session, billing_user_id, referrer_user.id)
|
||||
if smtp_configured():
|
||||
try:
|
||||
code = f"{secrets.randbelow(900000) + 100000}"
|
||||
await email_verify.store_code(email, code)
|
||||
await send_email_verify_code_email(email, code)
|
||||
except Exception as e:
|
||||
logger.warning("[Auth] Не удалось отправить код подтверждения email при регистрации: {}", e)
|
||||
logger.info("[Auth] Register success: identity={}, email={}, ip={}", identity.id, email, _client_ip(request))
|
||||
set_auth_cookie(response, token, request)
|
||||
set_is_admin_cookie(response, identity, request)
|
||||
return RegisterResponse(identity_id=identity.id)
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
async def login(
|
||||
body: LoginRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Вход по email и паролю. Возвращает identity_id и новый токен. Срок действия токена: """ + TOKEN_TTL_HINT + "."
|
||||
email = body.email.strip().lower()
|
||||
if not email:
|
||||
raise HTTPException(status_code=400, detail="Email обязателен")
|
||||
ip = _client_ip(request)
|
||||
try:
|
||||
from core.redis_cache import cache_get, cache_incr_checked
|
||||
from api.v2.routes.auth._fallback_limiter import check_and_increment
|
||||
lockout_key = f"login_lockout:{email}"
|
||||
locked = await cache_get(lockout_key)
|
||||
if locked:
|
||||
raise HTTPException(status_code=429, detail="Аккаунт временно заблокирован. Попробуйте через 15 минут.")
|
||||
rkey = f"login_pwd_rate:{ip}"
|
||||
count, redis_ok = await cache_incr_checked(rkey, 900)
|
||||
if not redis_ok:
|
||||
count = check_and_increment(rkey, 10, 900)
|
||||
if count > 10:
|
||||
raise HTTPException(status_code=429, detail="Слишком много попыток. Попробуйте позже.")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning("[Auth] Ошибка rate-limit проверки для email-логина: {}", e)
|
||||
result = await idb.login_by_email(session, email, body.password)
|
||||
if not result:
|
||||
try:
|
||||
from core.redis_cache import cache_incr, cache_set
|
||||
fail_key = f"login_fail:{email}"
|
||||
fails = await cache_incr(fail_key, 900)
|
||||
if fails >= 10:
|
||||
await cache_set(f"login_lockout:{email}", "1", 900)
|
||||
except Exception:
|
||||
pass
|
||||
raise HTTPException(status_code=401, detail="Неверный email или пароль")
|
||||
try:
|
||||
from core.redis_cache import cache_delete
|
||||
await cache_delete(f"login_fail:{email}")
|
||||
except Exception:
|
||||
pass
|
||||
identity, token = result
|
||||
await bind_identity_actor(request, session, identity)
|
||||
logger.info("[Auth] Login success: identity={}, email={}, ip={}, method=password", identity.id, email, ip)
|
||||
set_auth_cookie(response, token, request)
|
||||
set_is_admin_cookie(response, identity, request)
|
||||
return LoginResponse(identity_id=identity.id)
|
||||
|
||||
|
||||
@router.post("/send-login-code")
|
||||
async def send_login_code(
|
||||
body: SendLoginCodeRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Отправить код входа на email (SMTP + Redis)."""
|
||||
ip = _client_ip(request)
|
||||
try:
|
||||
from core.redis_cache import cache_incr_checked
|
||||
from api.v2.routes.auth._fallback_limiter import check_and_increment
|
||||
count, redis_ok = await cache_incr_checked(f"send_code_rate:{ip}", 3600)
|
||||
if not redis_ok:
|
||||
count = check_and_increment(f"send_code_rate:{ip}", 10, 3600)
|
||||
if count > 10:
|
||||
raise HTTPException(status_code=429, detail="Слишком много запросов кодов. Попробуйте позже.")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
pass
|
||||
if turnstile_enabled():
|
||||
if not await verify_turnstile_token(body.turnstile_token, ip):
|
||||
raise HTTPException(status_code=400, detail="Проверка CAPTCHA не пройдена")
|
||||
email_norm = normalize_login_email(body.email)
|
||||
if not email_norm:
|
||||
raise HTTPException(status_code=400, detail="Email обязателен")
|
||||
if is_disposable_email(email_norm):
|
||||
raise HTTPException(status_code=400, detail="Одноразовые email-адреса не поддерживаются")
|
||||
if not smtp_configured():
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Отправка кода недоступна: почта не настроена на сервере",
|
||||
)
|
||||
if not await redis_ready_for_login_codes():
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Сервис временно недоступен. Попробуйте позже.",
|
||||
)
|
||||
identity = await idb.get_identity_by_email(session, email_norm)
|
||||
if not identity:
|
||||
if not body.allow_register:
|
||||
return {"ok": True, "message": "Код отправлен на почту"}
|
||||
identity = await idb.create_identity(session, email=email_norm)
|
||||
ip = _client_ip(request)
|
||||
if not await try_consume_ip_send_budget(ip):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Слишком много запросов с вашего адреса. Попробуйте позже.",
|
||||
)
|
||||
if not await try_consume_email_send_budget(email_norm):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Слишком много запросов для этого адреса. Попробуйте позже.",
|
||||
)
|
||||
if not await try_acquire_resend_cooldown(email_norm):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Код уже отправлен. Подождите перед повторной отправкой.",
|
||||
)
|
||||
code = "".join(secrets.choice("0123456789") for _ in range(6))
|
||||
if not await store_code(email_norm, code):
|
||||
await release_resend_cooldown(email_norm)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Не удалось сохранить код. Попробуйте позже.",
|
||||
)
|
||||
try:
|
||||
await send_login_code_email(email_norm, code)
|
||||
except Exception:
|
||||
await release_resend_cooldown(email_norm)
|
||||
await delete_code(email_norm)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Не удалось отправить письмо. Попробуйте позже.",
|
||||
) from None
|
||||
return {"ok": True, "message": "Код отправлен на почту"}
|
||||
|
||||
|
||||
@router.post("/login-by-code", response_model=LoginResponse)
|
||||
async def login_by_code(
|
||||
body: LoginByCodeRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Вход по email и коду из письма."""
|
||||
email_norm = normalize_login_email(body.email)
|
||||
if not email_norm or not body.code or not body.code.strip():
|
||||
raise HTTPException(status_code=400, detail="Email и код обязательны")
|
||||
if not await redis_ready_for_login_codes():
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Сервис временно недоступен. Попробуйте позже.",
|
||||
)
|
||||
if not await try_consume_email_verify_budget(email_norm):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Слишком много попыток. Запросите новый код.",
|
||||
)
|
||||
if not await verify_and_consume_code(email_norm, body.code.strip()):
|
||||
raise HTTPException(status_code=401, detail="Неверный код или срок действия истёк")
|
||||
identity = await idb.get_identity_by_email(session, email_norm)
|
||||
if not identity:
|
||||
raise HTTPException(status_code=401, detail="Аккаунт не найден")
|
||||
if not getattr(identity, "email_verified", False):
|
||||
from sqlalchemy import update as sa_update
|
||||
|
||||
from database.models import Identity as IdentityModel
|
||||
await session.execute(sa_update(IdentityModel).where(IdentityModel.id == identity.id).values(email_verified=True))
|
||||
await bind_identity_actor(request, session, identity)
|
||||
token = await idb.issue_token_for_identity(session, identity)
|
||||
logger.info("[Auth] Login success: identity={}, email={}, method=code", identity.id, email_norm)
|
||||
set_auth_cookie(response, token, request)
|
||||
set_is_admin_cookie(response, identity, request)
|
||||
return LoginResponse(identity_id=identity.id)
|
||||
|
||||
|
||||
@router.post("/request-password-reset")
|
||||
async def request_password_reset(
|
||||
body: SendLoginCodeRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
email_norm = normalize_login_email(body.email)
|
||||
if not email_norm:
|
||||
raise HTTPException(status_code=400, detail="Email обязателен")
|
||||
if not smtp_configured() or not await pwd_reset.redis_ready():
|
||||
return _RESET_OK_MESSAGE
|
||||
identity = await idb.get_identity_by_email(session, email_norm)
|
||||
if not identity or not identity.password_hash:
|
||||
return _RESET_OK_MESSAGE
|
||||
ip = _client_ip(request)
|
||||
if not await pwd_reset.try_consume_ip_budget(ip):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Слишком много запросов с вашего адреса. Попробуйте позже.",
|
||||
)
|
||||
if not await pwd_reset.try_consume_email_send_budget(email_norm):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Слишком много запросов для этого адреса. Попробуйте позже.",
|
||||
)
|
||||
if not await pwd_reset.try_acquire_cooldown(email_norm):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Код уже отправлен. Подождите перед повторной отправкой.",
|
||||
)
|
||||
code = "".join(secrets.choice("0123456789") for _ in range(6))
|
||||
if not await pwd_reset.store_code(email_norm, code):
|
||||
await pwd_reset.release_cooldown(email_norm)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Не удалось сохранить код. Попробуйте позже.",
|
||||
)
|
||||
try:
|
||||
await send_password_reset_code_email(email_norm, code)
|
||||
except Exception:
|
||||
await pwd_reset.release_cooldown(email_norm)
|
||||
await pwd_reset.delete_code(email_norm)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Не удалось отправить письмо. Попробуйте позже.",
|
||||
) from None
|
||||
return _RESET_OK_MESSAGE
|
||||
|
||||
|
||||
@router.post("/confirm-password-reset", response_model=LoginResponse)
|
||||
async def confirm_password_reset(
|
||||
body: ConfirmPasswordResetRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
email_norm = normalize_login_email(body.email)
|
||||
if not email_norm or not body.code or not body.code.strip():
|
||||
raise HTTPException(status_code=400, detail="Email и код обязательны")
|
||||
if body.password != body.password_confirm:
|
||||
raise HTTPException(status_code=400, detail="Пароли не совпадают")
|
||||
if len(body.password) < 8:
|
||||
raise HTTPException(status_code=400, detail="Пароль минимум 8 символов")
|
||||
if not await pwd_reset.redis_ready():
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Сервис временно недоступен. Попробуйте позже.",
|
||||
)
|
||||
if not await pwd_reset.try_consume_email_verify_budget(email_norm):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Слишком много попыток. Запросите новый код.",
|
||||
)
|
||||
if not await pwd_reset.verify_and_consume_code(email_norm, body.code.strip()):
|
||||
raise HTTPException(status_code=401, detail="Неверный код или срок действия истёк")
|
||||
identity = await idb.get_identity_by_email(session, email_norm)
|
||||
if not identity:
|
||||
raise HTTPException(status_code=400, detail="Аккаунт не найден")
|
||||
updated = await idb.set_password_for_identity(session, identity.id, body.password)
|
||||
if not updated:
|
||||
raise HTTPException(status_code=400, detail="Не удалось обновить пароль")
|
||||
await bind_identity_actor(request, session, updated)
|
||||
token = await idb.issue_token_for_identity(session, updated)
|
||||
set_auth_cookie(response, token, request)
|
||||
set_is_admin_cookie(response, updated, request)
|
||||
return LoginResponse(identity_id=updated.id)
|
||||
@@ -0,0 +1,151 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import (
|
||||
bind_identity_actor,
|
||||
clear_auth_cookie,
|
||||
get_request_actor,
|
||||
get_session,
|
||||
verify_identity_token,
|
||||
)
|
||||
from api.v2.routes.auth._common import _resolve_partner_snapshot
|
||||
from api.v2.schemas.identities import (
|
||||
ChangePasswordRequest,
|
||||
IdentityResponse,
|
||||
SetPasswordRequest,
|
||||
)
|
||||
from api.v2.schemas.web_public import AccountSummaryResponse
|
||||
from database import (
|
||||
get_balance,
|
||||
get_keys,
|
||||
get_trial,
|
||||
identities as idb,
|
||||
)
|
||||
from database.models import CouponUsage, Gift, GiftUsage
|
||||
from database.referrals import get_referral_stats
|
||||
from database.web_notifications import count_unread_for_identity
|
||||
from utils.referral_codes import encode_referral_code
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/me", response_model=IdentityResponse)
|
||||
async def me(
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
"""Текущая идентичность по HttpOnly cookie `auth_token`."""
|
||||
return IdentityResponse.model_validate(identity)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(
|
||||
request: Request,
|
||||
response: Response,
|
||||
):
|
||||
"""Очищает auth cookie. Не требует валидной сессии — всегда возвращает ok."""
|
||||
clear_auth_cookie(response, request)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/summary", response_model=AccountSummaryResponse)
|
||||
async def auth_summary(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
actor = get_request_actor(request)
|
||||
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
|
||||
if billing_user_id is None:
|
||||
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
balance = float(await get_balance(session, billing_user_id))
|
||||
trial_status = await get_trial(session, billing_user_id)
|
||||
keys = await get_keys(session, billing_user_id)
|
||||
keys_total = len(keys) if keys else 0
|
||||
gifts_sent_r = await session.execute(
|
||||
select(func.count()).select_from(Gift).where(Gift.sender_user_id == billing_user_id)
|
||||
)
|
||||
gifts_sent = gifts_sent_r.scalar_one() or 0
|
||||
gifts_claimed_r = await session.execute(
|
||||
select(func.count()).select_from(GiftUsage).where(GiftUsage.user_id == billing_user_id)
|
||||
)
|
||||
gifts_claimed = gifts_claimed_r.scalar_one() or 0
|
||||
coupons_r = await session.execute(
|
||||
select(func.count()).select_from(CouponUsage).where(CouponUsage.user_id == billing_user_id)
|
||||
)
|
||||
coupons_used = coupons_r.scalar_one() or 0
|
||||
ref = await get_referral_stats(session, billing_user_id)
|
||||
partner = await _resolve_partner_snapshot(session, int(billing_user_id))
|
||||
unread_notifications = await count_unread_for_identity(session, identity.id)
|
||||
return AccountSummaryResponse(
|
||||
identity_id=identity.id,
|
||||
email=identity.email,
|
||||
tg_id=identity.tg_id,
|
||||
linked_telegram=identity.tg_id is not None,
|
||||
referral_code=encode_referral_code(int(billing_user_id)),
|
||||
balance=balance,
|
||||
trial_status=int(trial_status),
|
||||
keys_total=keys_total,
|
||||
referrals_total=int(ref.get("total_referrals") or 0),
|
||||
referrals_active=int(ref.get("active_referrals") or 0),
|
||||
referral_bonus_total=float(ref.get("total_referral_bonus") or 0),
|
||||
gifts_sent=int(gifts_sent),
|
||||
gifts_claimed=int(gifts_claimed),
|
||||
coupons_used=int(coupons_used),
|
||||
partner_enabled=bool(partner.get("partner_enabled", False)),
|
||||
partner_code=str(partner.get("partner_code") or ""),
|
||||
partner_balance=float(partner.get("partner_balance") or 0.0),
|
||||
partner_percent=float(partner.get("partner_percent") or 0.0),
|
||||
partner_percent_custom=bool(partner.get("partner_percent_custom", False)),
|
||||
partner_referred_total=int(partner.get("partner_referred_total") or 0),
|
||||
partner_payout_method=partner.get("partner_payout_method"),
|
||||
unread_notifications=int(unread_notifications),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/set-password")
|
||||
async def set_password(
|
||||
body: SetPasswordRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
if body.password != body.password_confirm:
|
||||
raise HTTPException(status_code=400, detail="Пароли не совпадают")
|
||||
updated = await idb.set_initial_password(session, identity.id, body.password)
|
||||
if not updated:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Пароль уже установлен или аккаунт недоступен",
|
||||
)
|
||||
await bind_identity_actor(request, session, updated)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/change-password")
|
||||
async def change_password(
|
||||
body: ChangePasswordRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
if body.password != body.password_confirm:
|
||||
raise HTTPException(status_code=400, detail="Новые пароли не совпадают")
|
||||
err = await idb.change_identity_password(
|
||||
session,
|
||||
identity.id,
|
||||
body.current_password,
|
||||
body.password,
|
||||
)
|
||||
if err == "no_password":
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Пароль ещё не установлен. Сначала задайте пароль в кабинете.",
|
||||
)
|
||||
if err == "wrong_password":
|
||||
raise HTTPException(status_code=401, detail="Неверный текущий пароль")
|
||||
refreshed = await idb.get_identity_by_id(session, identity.id)
|
||||
if refreshed:
|
||||
await bind_identity_actor(request, session, refreshed)
|
||||
return {"ok": True}
|
||||
@@ -0,0 +1,101 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
Field as PydanticField,
|
||||
)
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import (
|
||||
bind_identity_actor,
|
||||
get_session,
|
||||
set_auth_cookie,
|
||||
set_is_admin_cookie,
|
||||
verify_identity_token,
|
||||
)
|
||||
from api.v2.routes.auth._common import TELEGRAM_LOGIN_MAX_AGE, TOKEN_TTL_HINT, _client_ip
|
||||
from api.v2.schemas.identities import (
|
||||
IdentityResponse,
|
||||
LinkTelegramRequest,
|
||||
LoginResponse,
|
||||
LoginTelegramRequest,
|
||||
)
|
||||
from config import API_TOKEN
|
||||
from database import identities as idb
|
||||
from logger import logger
|
||||
from utils.telegram_login import verify_telegram_login
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class LoginTelegramWebAppRequest(BaseModel):
|
||||
init_data: str = PydanticField(..., min_length=1)
|
||||
|
||||
|
||||
@router.post("/login-telegram", response_model=LoginResponse)
|
||||
async def login_telegram(
|
||||
body: LoginTelegramRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
(
|
||||
"""Вход через Telegram Login Widget (кнопка на сайте). По tg_id находим или создаём Identity, выдаём токен. Срок действия токена: """
|
||||
+ TOKEN_TTL_HINT
|
||||
+ "."
|
||||
)
|
||||
payload = body.model_dump(mode="json")
|
||||
if not verify_telegram_login(payload, API_TOKEN, max_age_seconds=TELEGRAM_LOGIN_MAX_AGE):
|
||||
raise HTTPException(status_code=401, detail="Неверная подпись или устаревшие данные от Telegram")
|
||||
identity = await idb.get_or_create_identity_for_tg(session, body.id)
|
||||
await bind_identity_actor(request, session, identity)
|
||||
token = await idb.issue_token_for_identity(session, identity)
|
||||
logger.info("[Auth] Login success: identity={}, tg_id={}, ip={}, method=telegram_widget", identity.id, body.id, _client_ip(request))
|
||||
set_auth_cookie(response, token, request)
|
||||
set_is_admin_cookie(response, identity, request)
|
||||
return LoginResponse(identity_id=identity.id)
|
||||
|
||||
|
||||
@router.post("/login-telegram-webapp", response_model=LoginResponse)
|
||||
async def login_telegram_webapp(
|
||||
body: LoginTelegramWebAppRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Вход через Telegram WebApp initData. Валидирует HMAC, находит/создаёт Identity по tg_id."""
|
||||
from utils.telegram_login import verify_webapp_init_data
|
||||
result = verify_webapp_init_data(body.init_data, API_TOKEN)
|
||||
if not result:
|
||||
raise HTTPException(status_code=401, detail="Неверная подпись initData")
|
||||
tg_id = result.get("user_id")
|
||||
if not tg_id:
|
||||
raise HTTPException(status_code=401, detail="Не удалось определить пользователя из initData")
|
||||
identity = await idb.get_or_create_identity_for_tg(session, int(tg_id))
|
||||
await bind_identity_actor(request, session, identity)
|
||||
token = await idb.issue_token_for_identity(session, identity)
|
||||
logger.info("[Auth] Login success: identity={}, tg_id={}, ip={}, method=telegram_webapp", identity.id, tg_id, _client_ip(request))
|
||||
set_auth_cookie(response, token, request)
|
||||
set_is_admin_cookie(response, identity, request)
|
||||
return LoginResponse(identity_id=identity.id)
|
||||
|
||||
|
||||
@router.post("/link-telegram", response_model=IdentityResponse)
|
||||
async def link_telegram(
|
||||
body: LinkTelegramRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
"""Привязывает Telegram к текущей идентичности. Требуется подпись от Telegram Login Widget (доказательство владения аккаунтом)."""
|
||||
payload = body.model_dump(mode="json")
|
||||
if not verify_telegram_login(payload, API_TOKEN, max_age_seconds=TELEGRAM_LOGIN_MAX_AGE):
|
||||
raise HTTPException(status_code=401, detail="Неверная подпись или устаревшие данные от Telegram")
|
||||
result = await idb.attach_telegram(session, identity.id, body.id)
|
||||
if not result:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Этот Telegram уже привязан к другой идентичности",
|
||||
)
|
||||
await bind_identity_actor(request, session, result)
|
||||
return IdentityResponse.model_validate(result)
|
||||
@@ -0,0 +1,31 @@
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from services.coupons import resolve_percent_coupon
|
||||
from services.errors import ServiceError
|
||||
|
||||
|
||||
async def resolve_percent_coupon_pricing(
|
||||
session: AsyncSession,
|
||||
billing_user_id: int,
|
||||
base_price_rub: int,
|
||||
coupon_code: str | None,
|
||||
) -> tuple[int, int, int | None, str | None]:
|
||||
"""Применяет процентный купон. Бросает HTTPException при ошибке."""
|
||||
try:
|
||||
return await resolve_percent_coupon(
|
||||
session=session,
|
||||
billing_user_id=billing_user_id,
|
||||
base_price_rub=base_price_rub,
|
||||
coupon_code=coupon_code,
|
||||
)
|
||||
except ServiceError as e:
|
||||
status_map = {
|
||||
"not_found": 404,
|
||||
"limit_exceeded": 409,
|
||||
"validation_error": 400,
|
||||
}
|
||||
raise HTTPException(
|
||||
status_code=status_map.get(e.code, 400),
|
||||
detail=e.message,
|
||||
)
|
||||
@@ -1,8 +1,14 @@
|
||||
from fastapi import APIRouter
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
|
||||
from api.v2.base_crud import generate_crud_router
|
||||
from api.v2.schemas import CouponBase, CouponResponse, CouponUpdate
|
||||
from api.v2.schemas.web_public import CouponApplyRequest, CouponApplyResponse
|
||||
from api.depends import get_request_actor, get_session, verify_identity_token
|
||||
from database import identities as idb
|
||||
from database.models import Coupon
|
||||
from services.coupons import apply_fixed_coupon
|
||||
from services.errors import LimitExceededError, NotFoundError, ServiceError, ValidationError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
router = generate_crud_router(
|
||||
model=Coupon,
|
||||
@@ -13,3 +19,53 @@ router = generate_crud_router(
|
||||
parameter_name="code",
|
||||
enabled_methods=["get_all", "get_one", "create", "update", "delete"],
|
||||
)
|
||||
|
||||
|
||||
async def _resolve_coupon_user_id(session: AsyncSession, request: Request, identity) -> tuple[int, int | None]:
|
||||
actor = get_request_actor(request)
|
||||
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
|
||||
if billing_user_id is None:
|
||||
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
tg_id = actor.telegram_chat_id if actor else None
|
||||
return int(billing_user_id), tg_id
|
||||
|
||||
|
||||
def _service_error_to_http(e: ServiceError) -> HTTPException:
|
||||
status_map = {
|
||||
"not_found": 404,
|
||||
"limit_exceeded": 409,
|
||||
"validation_error": 400,
|
||||
"forbidden": 403,
|
||||
}
|
||||
return HTTPException(status_code=status_map.get(e.code, 400), detail=e.message)
|
||||
|
||||
|
||||
@router.post("/apply", response_model=CouponApplyResponse)
|
||||
async def apply_coupon(
|
||||
body: CouponApplyRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
user_id, tg_id = await _resolve_coupon_user_id(session, request, identity)
|
||||
try:
|
||||
result = await apply_fixed_coupon(
|
||||
session=session,
|
||||
user_id=user_id,
|
||||
tg_id=tg_id,
|
||||
code=str(body.code or ""),
|
||||
)
|
||||
await session.commit()
|
||||
return CouponApplyResponse(
|
||||
ok=True,
|
||||
message="Купон успешно активирован",
|
||||
coupon_code=result.coupon_code,
|
||||
amount=result.amount,
|
||||
balance=result.balance,
|
||||
)
|
||||
except ServiceError as e:
|
||||
await session.rollback()
|
||||
raise _service_error_to_http(e)
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise HTTPException(status_code=500, detail="Ошибка активации купона")
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, UTC
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_identity_admin
|
||||
from api.v2.schemas.flows import FlowCreate, FlowResponse, FlowUpdate
|
||||
from database.models import WebFlow
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _flow_to_response(flow: WebFlow) -> FlowResponse:
|
||||
return FlowResponse(
|
||||
id=flow.id,
|
||||
name=flow.name,
|
||||
nodes=flow.nodes or [],
|
||||
edges=flow.edges or [],
|
||||
entry_node_id=flow.entry_node_id,
|
||||
version=flow.version,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/flows/{flow_id}", response_model=FlowResponse)
|
||||
async def get_flow_public(flow_id: str, session: AsyncSession = Depends(get_session)):
|
||||
flow = await session.get(WebFlow, flow_id)
|
||||
if not flow:
|
||||
raise HTTPException(404, "Flow not found")
|
||||
return _flow_to_response(flow)
|
||||
|
||||
|
||||
@router.get("/admin/flows", response_model=list[FlowResponse])
|
||||
async def list_flows(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_identity=Depends(verify_identity_admin),
|
||||
):
|
||||
result = await session.execute(select(WebFlow))
|
||||
return [_flow_to_response(f) for f in result.scalars().all()]
|
||||
|
||||
|
||||
@router.get("/admin/flows/{flow_id}", response_model=FlowResponse)
|
||||
async def get_flow_admin(
|
||||
flow_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_identity=Depends(verify_identity_admin),
|
||||
):
|
||||
flow = await session.get(WebFlow, flow_id)
|
||||
if not flow:
|
||||
raise HTTPException(404, "Flow not found")
|
||||
return _flow_to_response(flow)
|
||||
|
||||
|
||||
@router.post("/admin/flows", response_model=FlowResponse, status_code=201)
|
||||
async def create_flow(
|
||||
body: FlowCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_identity=Depends(verify_identity_admin),
|
||||
):
|
||||
existing = await session.get(WebFlow, body.id)
|
||||
if existing:
|
||||
raise HTTPException(409, "Flow with this ID already exists")
|
||||
|
||||
flow = WebFlow(
|
||||
id=body.id,
|
||||
name=body.name,
|
||||
nodes=[n.model_dump() for n in body.nodes],
|
||||
edges=[e.model_dump() for e in body.edges],
|
||||
entry_node_id=body.entry_node_id,
|
||||
version=1,
|
||||
updated_at=datetime.now(UTC),
|
||||
)
|
||||
session.add(flow)
|
||||
await session.commit()
|
||||
await session.refresh(flow)
|
||||
return _flow_to_response(flow)
|
||||
|
||||
|
||||
@router.put("/admin/flows/{flow_id}", response_model=FlowResponse)
|
||||
async def update_flow(
|
||||
flow_id: str,
|
||||
body: FlowUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_identity=Depends(verify_identity_admin),
|
||||
):
|
||||
flow = await session.get(WebFlow, flow_id)
|
||||
if not flow:
|
||||
raise HTTPException(404, "Flow not found")
|
||||
|
||||
if body.name is not None:
|
||||
flow.name = body.name
|
||||
flow.nodes = [n.model_dump() for n in body.nodes]
|
||||
flow.edges = [e.model_dump() for e in body.edges]
|
||||
flow.entry_node_id = body.entry_node_id
|
||||
flow.version = flow.version + 1
|
||||
flow.updated_at = datetime.now(UTC)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(flow)
|
||||
return _flow_to_response(flow)
|
||||
|
||||
|
||||
@router.delete("/admin/flows/{flow_id}", status_code=204)
|
||||
async def delete_flow(
|
||||
flow_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_identity=Depends(verify_identity_admin),
|
||||
):
|
||||
flow = await session.get(WebFlow, flow_id)
|
||||
if not flow:
|
||||
raise HTTPException(404, "Flow not found")
|
||||
await session.delete(flow)
|
||||
await session.commit()
|
||||
+274
-5
@@ -1,14 +1,251 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path
|
||||
from sqlalchemy import delete, select
|
||||
from math import ceil
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_identity_admin
|
||||
from api.depends import (
|
||||
get_request_actor,
|
||||
get_session,
|
||||
validate_redirect_url,
|
||||
verify_identity_admin,
|
||||
verify_identity_token,
|
||||
)
|
||||
from api.v2.base_crud import generate_crud_router
|
||||
from api.v2.routes.tariffs import _resolve_default_web_payment_provider, _resolve_public_base_url
|
||||
from api.v2.schemas import GiftBase, GiftResponse, GiftUpdate, GiftUsageResponse
|
||||
from database.models import Gift, GiftUsage
|
||||
from api.v2.schemas.web_public import (
|
||||
GiftCreatePreviewResponse,
|
||||
GiftCreateRequest,
|
||||
GiftCreateResponse,
|
||||
GiftRedeemRequest,
|
||||
GiftRedeemResponse,
|
||||
GiftUsageEntry,
|
||||
MyGiftItem,
|
||||
MyGiftsResponse,
|
||||
)
|
||||
from config import GIFT_BUTTON
|
||||
from core.bootstrap import BUTTONS_CONFIG
|
||||
from database import (
|
||||
get_balance,
|
||||
identities as idb,
|
||||
)
|
||||
from database.access.resolution import resolve_user_optional
|
||||
from database.models import Gift, GiftUsage, Tariff
|
||||
from database.tariffs import get_tariff_by_id
|
||||
from database.temporary_data import create_temporary_data
|
||||
from services.errors import NotFoundError, ValidationError
|
||||
from services.formatting import get_site_gift_link
|
||||
from services.gifts import create_gift as service_create_gift
|
||||
from services.gifts import redeem_gift as service_redeem_gift
|
||||
from services.payments.payment_links import PaymentLinkRequest, create_payment_link
|
||||
from services.tariffs import calculate_config_price
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _check_gifts_enabled():
|
||||
if not bool(BUTTONS_CONFIG.get("GIFT_BUTTON_ENABLE", GIFT_BUTTON)):
|
||||
raise HTTPException(status_code=403, detail="Подарки отключены")
|
||||
|
||||
|
||||
@router.post("/create", tags=["Gifts"])
|
||||
async def create_gift_for_user(
|
||||
body: GiftCreateRequest,
|
||||
request: Request,
|
||||
preview: bool = Query(False),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
_check_gifts_enabled()
|
||||
actor = get_request_actor(request)
|
||||
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
|
||||
if billing_user_id is None:
|
||||
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
|
||||
tariff = await get_tariff_by_id(session, body.tariff_id)
|
||||
if not tariff or tariff.get("group_code") != "gifts" or not tariff.get("is_active", True):
|
||||
raise HTTPException(status_code=404, detail="Тариф не найден")
|
||||
|
||||
price = int(calculate_config_price(tariff, body.selected_device_limit, body.selected_traffic_gb))
|
||||
balance = float(await get_balance(session, billing_user_id))
|
||||
|
||||
required_amount = int(max(0, ceil(float(price) - balance)))
|
||||
|
||||
if preview:
|
||||
return GiftCreatePreviewResponse(
|
||||
ok=True,
|
||||
price_rub=price,
|
||||
balance_rub=balance,
|
||||
sufficient_funds=balance >= price,
|
||||
tariff_name=str(tariff.get("name", "")),
|
||||
duration_days=int(tariff.get("duration_days") or 0),
|
||||
)
|
||||
|
||||
if required_amount > 0:
|
||||
provider_id = str(body.provider_id or _resolve_default_web_payment_provider() or "").strip().upper()
|
||||
if not provider_id:
|
||||
raise HTTPException(status_code=503, detail="Нет доступных провайдеров оплаты")
|
||||
base_url = _resolve_public_base_url(request)
|
||||
success_url = validate_redirect_url(str(body.success_url or ""), f"{base_url}/payment-success")
|
||||
failure_url = validate_redirect_url(str(body.failure_url or ""), f"{base_url}/payment-failure")
|
||||
payment_request = PaymentLinkRequest(
|
||||
legacy_user_ref=int(billing_user_id),
|
||||
amount=required_amount,
|
||||
currency="RUB",
|
||||
provider_id=provider_id,
|
||||
success_url=success_url,
|
||||
failure_url=failure_url,
|
||||
metadata={
|
||||
"payment_flow": "gift_create",
|
||||
"tariff_id": int(body.tariff_id),
|
||||
"selected_device_limit": body.selected_device_limit,
|
||||
"selected_traffic_gb": body.selected_traffic_gb,
|
||||
"selected_price_rub": int(price),
|
||||
},
|
||||
)
|
||||
payment_result = await create_payment_link(session, payment_request)
|
||||
if not payment_result.success or not payment_result.payment_url or not payment_result.payment_id:
|
||||
raise HTTPException(status_code=400, detail=payment_result.error or "Не удалось создать ссылку оплаты")
|
||||
await create_temporary_data(
|
||||
session,
|
||||
int(billing_user_id),
|
||||
"waiting_for_payment",
|
||||
{
|
||||
"payment_flow": "gift_create",
|
||||
"tariff_id": int(body.tariff_id),
|
||||
"required_amount": int(required_amount),
|
||||
"selected_price_rub": int(price),
|
||||
"selected_device_limit": body.selected_device_limit,
|
||||
"selected_traffic_gb": body.selected_traffic_gb,
|
||||
},
|
||||
)
|
||||
return GiftCreateResponse(
|
||||
ok=True,
|
||||
message="Требуется оплата для создания подарка",
|
||||
payment_required=True,
|
||||
required_amount_rub=required_amount,
|
||||
payment_id=payment_result.payment_id,
|
||||
payment_url=payment_result.payment_url,
|
||||
)
|
||||
|
||||
from services.errors import InsufficientFundsError, NotFoundError
|
||||
|
||||
try:
|
||||
result = await service_create_gift(
|
||||
session=session,
|
||||
sender_user_ref=billing_user_id,
|
||||
tariff_id=body.tariff_id,
|
||||
selected_device_limit=body.selected_device_limit,
|
||||
selected_traffic_gb=body.selected_traffic_gb,
|
||||
selected_price_rub=price,
|
||||
)
|
||||
except InsufficientFundsError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from None
|
||||
except NotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e)) from None
|
||||
|
||||
new_balance = float(await get_balance(session, billing_user_id))
|
||||
return GiftCreateResponse(
|
||||
ok=True,
|
||||
message=f"Подарок создан — {result.tariff_name} на {result.duration_text}",
|
||||
gift_id=result.gift_id,
|
||||
site_gift_link=result.site_gift_link,
|
||||
tariff_name=result.tariff_name,
|
||||
duration_days=result.duration_days,
|
||||
price_charged=result.price_charged,
|
||||
balance_rub=new_balance,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/my", response_model=MyGiftsResponse, tags=["Gifts"])
|
||||
async def get_my_gifts(
|
||||
request: Request,
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
_check_gifts_enabled()
|
||||
actor = get_request_actor(request)
|
||||
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
|
||||
if billing_user_id is None:
|
||||
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
|
||||
base_filter = Gift.sender_user_id == billing_user_id
|
||||
total = (await session.execute(select(func.count()).select_from(Gift).where(base_filter))).scalar_one()
|
||||
|
||||
result = await session.execute(
|
||||
select(Gift).where(base_filter).order_by(Gift.created_at.desc()).limit(limit).offset(offset)
|
||||
)
|
||||
gifts = result.scalars().all()
|
||||
|
||||
tariff_ids = {g.tariff_id for g in gifts if g.tariff_id}
|
||||
tariff_map: dict[int, str] = {}
|
||||
duration_map: dict[int, int] = {}
|
||||
if tariff_ids:
|
||||
tariff_rows = await session.execute(select(Tariff).where(Tariff.id.in_(tariff_ids)))
|
||||
for t in tariff_rows.scalars().all():
|
||||
tariff_map[t.id] = t.name or ""
|
||||
duration_map[t.id] = int(t.duration_days or 0)
|
||||
|
||||
gift_ids = [g.gift_id for g in gifts]
|
||||
usages_map: dict[str, list[GiftUsageEntry]] = {gid: [] for gid in gift_ids}
|
||||
if gift_ids:
|
||||
usage_rows = await session.execute(select(GiftUsage).where(GiftUsage.gift_id.in_(gift_ids)))
|
||||
for u in usage_rows.scalars().all():
|
||||
usages_map.setdefault(u.gift_id, []).append(
|
||||
GiftUsageEntry(
|
||||
user_id=int(u.user_id),
|
||||
used_at=u.used_at.isoformat() if u.used_at else None,
|
||||
)
|
||||
)
|
||||
|
||||
items = []
|
||||
for g in gifts:
|
||||
items.append(
|
||||
MyGiftItem(
|
||||
gift_id=g.gift_id,
|
||||
tariff_name=tariff_map.get(g.tariff_id, ""),
|
||||
duration_days=duration_map.get(g.tariff_id, 0),
|
||||
price_rub=int(g.selected_price_rub or 0),
|
||||
created_at=g.created_at.isoformat() if g.created_at else None,
|
||||
expiry_time=g.expiry_time.isoformat() if g.expiry_time else None,
|
||||
is_used=bool(g.is_used),
|
||||
is_unlimited=bool(g.is_unlimited),
|
||||
max_usages=g.max_usages,
|
||||
site_gift_link=get_site_gift_link(g.gift_id),
|
||||
usages=usages_map.get(g.gift_id, []),
|
||||
)
|
||||
)
|
||||
|
||||
return MyGiftsResponse(ok=True, gifts=items, total=total, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.delete("/my/{gift_id}", response_model=dict, tags=["Gifts"])
|
||||
async def delete_my_gift(
|
||||
request: Request,
|
||||
gift_id: str = Path(...),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
"""Удаляет свой подарок."""
|
||||
actor = get_request_actor(request)
|
||||
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
|
||||
if billing_user_id is None:
|
||||
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
|
||||
result = await session.execute(select(Gift).where(Gift.gift_id == gift_id))
|
||||
gift = result.scalar_one_or_none()
|
||||
if not gift or gift.sender_user_id != billing_user_id:
|
||||
raise HTTPException(status_code=404, detail="Подарок не найден")
|
||||
await session.execute(delete(GiftUsage).where(GiftUsage.gift_id == gift_id))
|
||||
await session.delete(gift)
|
||||
await session.commit()
|
||||
return {"ok": True, "message": "Подарок удалён"}
|
||||
|
||||
|
||||
gift_router = generate_crud_router(
|
||||
model=Gift,
|
||||
schema_response=GiftResponse,
|
||||
@@ -21,6 +258,35 @@ gift_router = generate_crud_router(
|
||||
router.include_router(gift_router, prefix="", tags=["Gifts"])
|
||||
|
||||
|
||||
@router.post("/redeem", response_model=GiftRedeemResponse, tags=["Gifts"])
|
||||
async def redeem_gift(
|
||||
body: GiftRedeemRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
_check_gifts_enabled()
|
||||
actor = get_request_actor(request)
|
||||
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
|
||||
if billing_user_id is None:
|
||||
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
try:
|
||||
result = await service_redeem_gift(session, body.gift_code, billing_user_id)
|
||||
except ValidationError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from None
|
||||
except NotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e)) from None
|
||||
except Exception:
|
||||
raise HTTPException(status_code=500, detail="Не удалось активировать подарок") from None
|
||||
return GiftRedeemResponse(
|
||||
ok=True,
|
||||
message=result.message,
|
||||
gift_id=result.gift_id,
|
||||
tariff_id=result.tariff_id,
|
||||
duration_days=result.duration_days,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/by_tg_id/{tg_id}", response_model=list[GiftResponse], tags=["Gifts"])
|
||||
async def get_gifts_by_tg_id(
|
||||
tg_id: int = Path(...),
|
||||
@@ -28,7 +294,10 @@ async def get_gifts_by_tg_id(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Список подарков по tg_id отправителя."""
|
||||
result = await session.execute(select(Gift).where(Gift.sender_tg_id == tg_id))
|
||||
u = await resolve_user_optional(session, tg_id)
|
||||
if u is None:
|
||||
raise HTTPException(status_code=404, detail="Gifts not found")
|
||||
result = await session.execute(select(Gift).where(Gift.sender_user_id == u.id))
|
||||
gifts = result.scalars().all()
|
||||
if not gifts:
|
||||
raise HTTPException(status_code=404, detail="Gifts not found")
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
from ._common import router, user_router
|
||||
from . import admin, user # noqa: F401 — import triggers endpoint registration
|
||||
|
||||
__all__ = ["router", "user_router"]
|
||||
@@ -0,0 +1,279 @@
|
||||
import asyncio
|
||||
import re
|
||||
|
||||
from base64 import b64encode
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
from math import ceil
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import qrcode
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Request, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import (
|
||||
get_request_actor,
|
||||
get_session,
|
||||
validate_redirect_url,
|
||||
verify_identity_admin,
|
||||
verify_identity_token,
|
||||
)
|
||||
from api.v2.base_crud import generate_crud_router
|
||||
from api.v2.routes.coupon_pricing import resolve_percent_coupon_pricing
|
||||
from api.v2.schemas import KeyBase, KeyCreateRequest, KeyResponse, KeyUpdate
|
||||
from api.v2.schemas.web_public import (
|
||||
AccountKeyActionResponse,
|
||||
AccountKeyActionsAvailability,
|
||||
AccountKeyActionsConfigResponse,
|
||||
AccountKeyAddonOptionResponse,
|
||||
AccountKeyAddonsPreviewRequest,
|
||||
AccountKeyAddonsPreviewResponse,
|
||||
AccountKeyAliasUpdateRequest,
|
||||
AccountKeyApplyAddonsResponse,
|
||||
AccountKeyChangeLocationRequest,
|
||||
AccountKeyChangeLocationResponse,
|
||||
AccountKeyDetailsResponse,
|
||||
AccountKeyLocationOptionResponse,
|
||||
AccountKeyLocationsResponse,
|
||||
AccountKeyQrResponse,
|
||||
AccountKeyRenewRequest,
|
||||
AccountKeyRenewResponse,
|
||||
AccountKeyResetHwidResponse,
|
||||
AccountKeyResponse,
|
||||
)
|
||||
from config import (
|
||||
ENABLE_DELETE_KEY_BUTTON,
|
||||
HWID_RESET_BUTTON,
|
||||
INSTRUCTIONS_BUTTON,
|
||||
QRCODE,
|
||||
REMNAWAVE_LOGIN,
|
||||
REMNAWAVE_PASSWORD,
|
||||
USE_COUNTRY_SELECTION,
|
||||
)
|
||||
from core.bootstrap import BUTTONS_CONFIG, MODES_CONFIG, PAYMENTS_CONFIG, TARIFFS_CONFIG
|
||||
from core.settings.tariffs_config import normalize_tariff_config
|
||||
from database import (
|
||||
check_server_name_by_cluster,
|
||||
filter_cluster_by_subgroup,
|
||||
get_balance,
|
||||
get_key_details,
|
||||
get_keys,
|
||||
get_tariff_by_id,
|
||||
identities as idb,
|
||||
save_key_config_with_mode,
|
||||
update_balance,
|
||||
)
|
||||
from database.access.resolution import resolve_user_optional
|
||||
from database.coupons import mark_coupon_used
|
||||
from database.models import Key, Server, ServerSpecialgroup, Tariff
|
||||
from database.temporary_data import create_temporary_data
|
||||
from handlers.buttons import CONNECT_DEVICE, ROUTER_BUTTON, TV_BUTTON
|
||||
from handlers.keys.key_view import build_key_view_payload
|
||||
from handlers.tariffs.addons.key_addons_pack import calc_pack_full_price_rub, get_pack_flags
|
||||
from handlers.tariffs.addons.utils import calc_remaining_ratio_seconds, is_not_downgrade
|
||||
from handlers.utils import ALLOWED_GROUP_CODES, is_full_remnawave_cluster
|
||||
from logger import logger
|
||||
from panels._3xui import delete_client, get_xui_instance
|
||||
from panels.remnawave import RemnawaveAPI, get_vless_link_for_remnawave_by_username
|
||||
from panels.remnawave_runtime import get_remnawave_profile, invalidate_remnawave_profile, with_remnawave_api
|
||||
from services.operations import (
|
||||
create_client_on_server,
|
||||
create_key_on_cluster,
|
||||
delete_key_from_cluster,
|
||||
renew_key_in_cluster,
|
||||
)
|
||||
from services.operations.aggregated_links import make_aggregated_link
|
||||
from services.payments.payment_links import PaymentLinkRequest, create_payment_link
|
||||
from services.payments.providers import WEB_LINK_PROVIDER_IDS
|
||||
from services.tariffs import calculate_config_price
|
||||
from services.tariffs.tariff_display import GB, get_effective_limits_for_key, get_key_tariff_addons_state
|
||||
|
||||
|
||||
|
||||
router = generate_crud_router(
|
||||
model=Key,
|
||||
schema_response=KeyResponse,
|
||||
schema_create=KeyBase,
|
||||
schema_update=KeyUpdate,
|
||||
identifier_field="tg_id",
|
||||
extra_get_by_email=True,
|
||||
enabled_methods=["get_all", "get_one", "get_by_email", "get_all_by_field"],
|
||||
)
|
||||
user_router = APIRouter()
|
||||
|
||||
|
||||
|
||||
|
||||
def _key_actions_config() -> AccountKeyActionsConfigResponse:
|
||||
addons_mode = str(TARIFFS_CONFIG.get("KEY_ADDONS_PACK_MODE", "") or "").strip().lower()
|
||||
if addons_mode not in {"", "traffic", "devices", "all"}:
|
||||
addons_mode = ""
|
||||
addons_enabled_default = addons_mode in {"", "traffic", "devices", "all"}
|
||||
return AccountKeyActionsConfigResponse(
|
||||
renew_enabled=True,
|
||||
delete_enabled=bool(BUTTONS_CONFIG.get("DELETE_KEY_BUTTON_ENABLE", ENABLE_DELETE_KEY_BUTTON)),
|
||||
qr_enabled=bool(BUTTONS_CONFIG.get("QRCODE_BUTTON_ENABLE", QRCODE)),
|
||||
hwid_reset_enabled=bool(BUTTONS_CONFIG.get("HWID_RESET_BUTTON_ENABLE", HWID_RESET_BUTTON)),
|
||||
country_change_enabled=bool(MODES_CONFIG.get("COUNTRY_SELECTION_ENABLED", USE_COUNTRY_SELECTION)),
|
||||
instructions_enabled=bool(BUTTONS_CONFIG.get("INSTRUCTIONS_BUTTON_ENABLE", INSTRUCTIONS_BUTTON)),
|
||||
addons_enabled=addons_enabled_default,
|
||||
addons_mode=addons_mode,
|
||||
tv_connect_enabled=bool(BUTTONS_CONFIG.get("ANDROID_TV_BUTTON_ENABLE")),
|
||||
)
|
||||
|
||||
|
||||
def _extract_key_actions_from_markup(markup) -> AccountKeyActionsAvailability:
|
||||
actions = AccountKeyActionsAvailability()
|
||||
rows = getattr(markup, "inline_keyboard", None) or []
|
||||
for row in rows:
|
||||
for button in row:
|
||||
callback_data = str(getattr(button, "callback_data", "") or "")
|
||||
text = str(getattr(button, "text", "") or "")
|
||||
has_url = bool(getattr(button, "url", None))
|
||||
has_web_app = bool(getattr(button, "web_app", None))
|
||||
if callback_data.startswith("connect_router|") or text == ROUTER_BUTTON:
|
||||
actions.can_connect_router = True
|
||||
if callback_data.startswith("connect_tv|") or text == TV_BUTTON:
|
||||
actions.can_connect_tv = True
|
||||
if callback_data.startswith("connect_device|") or (text == CONNECT_DEVICE and (has_url or has_web_app)):
|
||||
actions.can_connect_device = True
|
||||
if callback_data.startswith("renew_key|"):
|
||||
actions.can_renew = True
|
||||
if callback_data.startswith("key_addons|"):
|
||||
actions.can_addons = True
|
||||
if callback_data.startswith("reset_hwid|"):
|
||||
actions.can_reset_hwid = True
|
||||
if callback_data.startswith("show_qr|"):
|
||||
actions.can_qr = True
|
||||
if callback_data.startswith("delete_key|"):
|
||||
actions.can_delete = True
|
||||
if callback_data.startswith("change_location|"):
|
||||
actions.can_change_location = True
|
||||
return actions
|
||||
|
||||
|
||||
async def _resolve_available_location_servers(session: AsyncSession, db_key: Key) -> list[str]:
|
||||
current_server = str(getattr(db_key, "server_id", "") or "")
|
||||
if not current_server:
|
||||
return []
|
||||
cluster_info = await check_server_name_by_cluster(session, current_server)
|
||||
if not cluster_info:
|
||||
return []
|
||||
cluster_name = str(cluster_info.get("cluster_name") or "")
|
||||
if not cluster_name:
|
||||
return []
|
||||
q = (
|
||||
select(
|
||||
Server.id,
|
||||
Server.server_name,
|
||||
Server.api_url,
|
||||
Server.panel_type,
|
||||
Server.enabled,
|
||||
Server.max_keys,
|
||||
)
|
||||
.where(Server.cluster_name == cluster_name)
|
||||
.where(Server.server_name != current_server)
|
||||
)
|
||||
servers = [dict(m) for m in (await session.execute(q)).mappings().all()]
|
||||
if not servers:
|
||||
return []
|
||||
server_ids = [s["id"] for s in servers if s.get("id") is not None]
|
||||
groups_map: dict[int, list[str]] = {}
|
||||
if server_ids:
|
||||
r = await session.execute(
|
||||
select(ServerSpecialgroup.server_id, ServerSpecialgroup.group_code).where(
|
||||
ServerSpecialgroup.server_id.in_(server_ids)
|
||||
)
|
||||
)
|
||||
for sid, gc in r.all():
|
||||
groups_map.setdefault(int(sid), []).append(gc)
|
||||
for server in servers:
|
||||
sid_raw = server.get("id")
|
||||
sid = int(sid_raw) if sid_raw is not None else -1
|
||||
server["special_groups"] = [g for g in groups_map.get(sid, []) if g in ALLOWED_GROUP_CODES]
|
||||
key_tariff_id = getattr(db_key, "tariff_id", None)
|
||||
subgroup_title = None
|
||||
tariff_dict = None
|
||||
if key_tariff_id:
|
||||
tariff_dict = await get_tariff_by_id(session, int(key_tariff_id))
|
||||
if tariff_dict:
|
||||
subgroup_title = tariff_dict.get("subgroup_title")
|
||||
available_servers = [s for s in servers if bool(s.get("enabled", True))]
|
||||
if subgroup_title and available_servers:
|
||||
filtered_servers = await filter_cluster_by_subgroup(
|
||||
session=session,
|
||||
cluster=available_servers,
|
||||
target_subgroup=str(subgroup_title).strip(),
|
||||
cluster_id=cluster_name,
|
||||
tariff_id=int(key_tariff_id) if key_tariff_id else None,
|
||||
)
|
||||
if filtered_servers:
|
||||
available_servers = filtered_servers
|
||||
else:
|
||||
available_servers = []
|
||||
if available_servers and tariff_dict:
|
||||
special = None
|
||||
gc = str(tariff_dict.get("group_code") or "").lower()
|
||||
if gc and gc in ALLOWED_GROUP_CODES:
|
||||
special = gc
|
||||
if special:
|
||||
bound_servers = [s for s in available_servers if special in (s.get("special_groups") or [])]
|
||||
if bound_servers:
|
||||
available_servers = bound_servers
|
||||
names = sorted(
|
||||
{
|
||||
str(s.get("server_name") or "").strip()
|
||||
for s in available_servers
|
||||
if str(s.get("server_name") or "").strip()
|
||||
}
|
||||
)
|
||||
return names
|
||||
|
||||
|
||||
async def _resolve_billing_user_id(request: Request, identity, session: AsyncSession) -> int:
|
||||
actor = get_request_actor(request)
|
||||
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
|
||||
if billing_user_id is None:
|
||||
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
return int(billing_user_id)
|
||||
|
||||
|
||||
def _resolve_public_base_url(request: Request) -> str:
|
||||
origin = str(request.headers.get("origin") or "").strip()
|
||||
if origin.startswith(("http://", "https://")):
|
||||
return origin.rstrip("/")
|
||||
referer = str(request.headers.get("referer") or request.headers.get("referrer") or "").strip()
|
||||
if referer.startswith(("http://", "https://")):
|
||||
parsed = urlsplit(referer)
|
||||
if parsed.scheme and parsed.netloc:
|
||||
return f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
|
||||
forwarded_host = str(request.headers.get("x-forwarded-host") or "").strip()
|
||||
host = forwarded_host or str(request.headers.get("host") or "").strip()
|
||||
forwarded_proto = str(request.headers.get("x-forwarded-proto") or "").split(",", 1)[0].strip().lower()
|
||||
scheme = forwarded_proto if forwarded_proto in {"http", "https"} else request.url.scheme
|
||||
if host:
|
||||
return f"{scheme}://{host}".rstrip("/")
|
||||
return str(request.base_url).rstrip("/")
|
||||
|
||||
|
||||
def _resolve_default_web_payment_provider() -> str | None:
|
||||
for provider_id in WEB_LINK_PROVIDER_IDS:
|
||||
if bool(PAYMENTS_CONFIG.get(provider_id)):
|
||||
return provider_id
|
||||
return WEB_LINK_PROVIDER_IDS[0] if WEB_LINK_PROVIDER_IDS else None
|
||||
|
||||
|
||||
def _normalize_expiry_ms(raw_value: int | float | None) -> int:
|
||||
if not raw_value:
|
||||
return 0
|
||||
value = int(raw_value)
|
||||
if value > 10**13:
|
||||
value //= 1000
|
||||
elif value < 10**10:
|
||||
value *= 1000
|
||||
return value
|
||||
|
||||
|
||||
@@ -1,26 +1,5 @@
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import Body, Depends, HTTPException, Path, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_identity_admin
|
||||
from api.v2.schemas import KeyBase, KeyCreateRequest, KeyResponse, KeyUpdate
|
||||
from api.v2.base_crud import generate_crud_router
|
||||
from database.models import Key, Tariff
|
||||
from handlers.keys.operations import create_key_on_cluster, delete_key_from_cluster, renew_key_in_cluster
|
||||
from logger import logger
|
||||
|
||||
router = generate_crud_router(
|
||||
model=Key,
|
||||
schema_response=KeyResponse,
|
||||
schema_create=KeyBase,
|
||||
schema_update=KeyUpdate,
|
||||
identifier_field="tg_id",
|
||||
extra_get_by_email=True,
|
||||
enabled_methods=["get_all", "get_one", "get_by_email", "get_all_by_field"],
|
||||
)
|
||||
|
||||
from ._common import * # noqa: F401,F403
|
||||
from ._common import router, user_router # noqa: F401
|
||||
|
||||
@router.delete("/by_email/{email}", response_model=dict)
|
||||
async def delete_key_by_email(
|
||||
@@ -60,7 +39,10 @@ async def get_router_keys_by_tg_id(
|
||||
tariff_ids = [row[0] for row in tariffs_result.all()]
|
||||
if not tariff_ids:
|
||||
return []
|
||||
keys_result = await session.execute(select(Key).where(Key.tg_id == tg_id, Key.tariff_id.in_(tariff_ids)))
|
||||
u = await resolve_user_optional(session, tg_id)
|
||||
if u is None:
|
||||
return []
|
||||
keys_result = await session.execute(select(Key).where(Key.user_id == u.id, Key.tariff_id.in_(tariff_ids)))
|
||||
return keys_result.scalars().all()
|
||||
|
||||
|
||||
@@ -132,3 +114,4 @@ async def create_key_api(
|
||||
except Exception as e:
|
||||
logger.error(f"[API] Ошибка при создании ключа: {e}")
|
||||
raise HTTPException(status_code=500, detail="Ошибка при создании ключа")
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from . import addons, core, hwid, location, renew # noqa: F401 — trigger registration
|
||||
@@ -0,0 +1,619 @@
|
||||
"""User-facing key endpoints (/api/keys/*).
|
||||
|
||||
Регистрирует эндпоинты на ``user_router`` из ``_common``. Импорт этого модуля
|
||||
из ``__init__.py`` запускает регистрацию декораторов.
|
||||
"""
|
||||
|
||||
from .._common import * # noqa: F401,F403 — подтягиваем все имена для endpoints
|
||||
from .._common import (
|
||||
_key_actions_config,
|
||||
_resolve_available_location_servers,
|
||||
_resolve_billing_user_id,
|
||||
_resolve_default_web_payment_provider,
|
||||
_resolve_public_base_url,
|
||||
_normalize_expiry_ms,
|
||||
router,
|
||||
user_router,
|
||||
)
|
||||
|
||||
|
||||
@user_router.get("/{client_id}/addons-preview", response_model=AccountKeyAddonsPreviewResponse)
|
||||
async def user_key_addons_preview(
|
||||
client_id: str,
|
||||
request: Request,
|
||||
selected_device_limit: int | None = Query(None),
|
||||
selected_traffic_gb: int | None = Query(None),
|
||||
include_device: bool | None = Query(None),
|
||||
include_traffic: bool | None = Query(None),
|
||||
coupon_code: str | None = Query(None),
|
||||
force_web: bool = Query(False),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
actions = _key_actions_config()
|
||||
if not force_web and not actions.addons_enabled:
|
||||
raise HTTPException(status_code=403, detail="Доп. опции отключены в настройках")
|
||||
billing_user_id = await _resolve_billing_user_id(request, identity, session)
|
||||
db_key = (
|
||||
await session.execute(
|
||||
select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if db_key is None:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
tariff_id = getattr(db_key, "tariff_id", None)
|
||||
if not tariff_id:
|
||||
raise HTTPException(status_code=400, detail="Для подписки не назначен тариф")
|
||||
tariff = await get_tariff_by_id(session, int(tariff_id))
|
||||
if not tariff:
|
||||
raise HTTPException(status_code=404, detail="Тариф не найден")
|
||||
key_details = await get_key_details(session, str(getattr(db_key, "email", "") or ""))
|
||||
if not key_details:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
(
|
||||
_tariff_name,
|
||||
_subgroup_title,
|
||||
_traffic_limit_gb,
|
||||
_device_limit,
|
||||
_panel,
|
||||
is_tariff_configurable,
|
||||
addons_devices_enabled,
|
||||
addons_traffic_enabled,
|
||||
) = await get_key_tariff_addons_state(session=session, key_record=key_details, db_key=db_key)
|
||||
if not is_tariff_configurable:
|
||||
raise HTTPException(status_code=400, detail="Тариф не поддерживает доп. опции")
|
||||
cfg = normalize_tariff_config(tariff)
|
||||
raw_device_options = cfg.get("device_options") or tariff.get("device_options") or []
|
||||
raw_traffic_options = cfg.get("traffic_options_gb") or tariff.get("traffic_options_gb") or []
|
||||
device_options: list[int] = []
|
||||
for value in raw_device_options:
|
||||
try:
|
||||
device_options.append(int(value))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
traffic_options: list[int] = []
|
||||
for value in raw_traffic_options:
|
||||
try:
|
||||
traffic_options.append(int(value))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
device_options = sorted(set(device_options), key=lambda val: (int(val == 0), val))
|
||||
traffic_options = sorted(set(traffic_options), key=lambda val: (int(val == 0), val))
|
||||
has_device_option = bool(device_options) and bool(addons_devices_enabled)
|
||||
has_traffic_option = bool(traffic_options) and bool(addons_traffic_enabled)
|
||||
pack_devices, pack_traffic, pack_mode = get_pack_flags()
|
||||
if pack_mode:
|
||||
has_device_option = has_device_option and bool(pack_devices)
|
||||
has_traffic_option = has_traffic_option and bool(pack_traffic)
|
||||
if not has_device_option:
|
||||
device_options = []
|
||||
if not has_traffic_option:
|
||||
traffic_options = []
|
||||
if not has_device_option and not has_traffic_option:
|
||||
raise HTTPException(status_code=400, detail="Доп. опции для этой подписки недоступны")
|
||||
selected_device_limit_db = key_details.get("selected_device_limit")
|
||||
selected_traffic_limit_db = key_details.get("selected_traffic_limit")
|
||||
current_device_limit_db = key_details.get("current_device_limit")
|
||||
current_traffic_limit_db = key_details.get("current_traffic_limit")
|
||||
base_devices = tariff.get("device_limit")
|
||||
base_devices = int(base_devices) if base_devices is not None else None
|
||||
base_traffic_bytes = tariff.get("traffic_limit")
|
||||
base_traffic_gb_from_tariff = int(base_traffic_bytes / GB) if base_traffic_bytes else None
|
||||
current_device_limit = (
|
||||
int(current_device_limit_db)
|
||||
if current_device_limit_db is not None
|
||||
else (int(selected_device_limit_db) if selected_device_limit_db is not None else base_devices)
|
||||
)
|
||||
current_traffic_gb = (
|
||||
int(current_traffic_limit_db)
|
||||
if current_traffic_limit_db is not None
|
||||
else (int(selected_traffic_limit_db) if selected_traffic_limit_db is not None else base_traffic_gb_from_tariff)
|
||||
)
|
||||
if pack_mode and current_device_limit is not None and int(current_device_limit) == 0:
|
||||
has_device_option = False
|
||||
device_options = []
|
||||
if pack_mode and current_traffic_gb is not None and int(current_traffic_gb) == 0:
|
||||
has_traffic_option = False
|
||||
traffic_options = []
|
||||
if not has_device_option and not has_traffic_option:
|
||||
raise HTTPException(status_code=400, detail="Доп. опции для этой подписки недоступны")
|
||||
if pack_mode:
|
||||
include_device_effective = bool(include_device) if include_device is not None else selected_device_limit is not None
|
||||
include_traffic_effective = bool(include_traffic) if include_traffic is not None else selected_traffic_gb is not None
|
||||
selected_device = (
|
||||
selected_device_limit
|
||||
if selected_device_limit is not None
|
||||
else None
|
||||
)
|
||||
selected_traffic = (
|
||||
selected_traffic_gb
|
||||
if selected_traffic_gb is not None
|
||||
else None
|
||||
)
|
||||
else:
|
||||
include_device_effective = has_device_option
|
||||
include_traffic_effective = has_traffic_option
|
||||
selected_device = selected_device_limit if selected_device_limit is not None else current_device_limit
|
||||
selected_traffic = selected_traffic_gb if selected_traffic_gb is not None else current_traffic_gb
|
||||
if has_device_option and include_device_effective and selected_device is not None and int(selected_device) not in device_options:
|
||||
raise HTTPException(status_code=400, detail="Выбранный пакет устройств недоступен")
|
||||
if has_traffic_option and include_traffic_effective and selected_traffic is not None and int(selected_traffic) not in traffic_options:
|
||||
raise HTTPException(status_code=400, detail="Выбранный пакет трафика недоступен")
|
||||
current_devices_for_price = int(current_device_limit) if current_device_limit is not None else None
|
||||
current_traffic_for_price = int(current_traffic_gb) if current_traffic_gb is not None else None
|
||||
base_price_for_current = int(
|
||||
calculate_config_price(
|
||||
tariff=tariff,
|
||||
selected_device_limit=current_devices_for_price,
|
||||
selected_traffic_gb=current_traffic_for_price,
|
||||
)
|
||||
)
|
||||
if pack_mode:
|
||||
diff_full = int(
|
||||
calc_pack_full_price_rub(
|
||||
tariff=tariff,
|
||||
has_device_option=bool(has_device_option and include_device_effective),
|
||||
has_traffic_option=bool(has_traffic_option and include_traffic_effective),
|
||||
selected_devices=int(selected_device) if has_device_option and include_device_effective and selected_device is not None else None,
|
||||
selected_traffic_gb=int(selected_traffic) if has_traffic_option and include_traffic_effective and selected_traffic is not None else None,
|
||||
)
|
||||
)
|
||||
recalc_enabled = bool(
|
||||
MODES_CONFIG.get(
|
||||
"KEY_ADDONS_RECALC_PRICE",
|
||||
TARIFFS_CONFIG.get("KEY_ADDONS_RECALC_PRICE", False),
|
||||
)
|
||||
)
|
||||
if recalc_enabled:
|
||||
remaining_seconds, total_seconds = calc_remaining_ratio_seconds(
|
||||
key_details.get("expiry_time"),
|
||||
tariff,
|
||||
)
|
||||
extra_price_rub = int((diff_full * remaining_seconds + total_seconds - 1) // total_seconds)
|
||||
else:
|
||||
extra_price_rub = diff_full
|
||||
total_price_rub = int(base_price_for_current + diff_full)
|
||||
else:
|
||||
total_price_rub = int(
|
||||
calculate_config_price(
|
||||
tariff=tariff,
|
||||
selected_device_limit=int(selected_device) if has_device_option and include_device_effective and selected_device is not None else None,
|
||||
selected_traffic_gb=int(selected_traffic) if has_traffic_option and include_traffic_effective and selected_traffic is not None else None,
|
||||
)
|
||||
)
|
||||
extra_price_rub = int(max(0, total_price_rub - base_price_for_current))
|
||||
final_extra_price_rub, discount_rub, _coupon_id, applied_coupon_code = await resolve_percent_coupon_pricing(
|
||||
session=session,
|
||||
billing_user_id=int(billing_user_id),
|
||||
base_price_rub=int(max(0, extra_price_rub)),
|
||||
coupon_code=coupon_code,
|
||||
)
|
||||
return AccountKeyAddonsPreviewResponse(
|
||||
client_id=str(getattr(db_key, "client_id", "") or ""),
|
||||
tariff_id=int(tariff_id),
|
||||
addons_mode=str(pack_mode or ""),
|
||||
has_device_option=bool(has_device_option),
|
||||
has_traffic_option=bool(has_traffic_option),
|
||||
current_device_limit=int(current_device_limit) if current_device_limit is not None else None,
|
||||
current_traffic_gb=int(current_traffic_gb) if current_traffic_gb is not None else None,
|
||||
selected_device_limit=int(selected_device) if has_device_option and include_device_effective and selected_device is not None else None,
|
||||
selected_traffic_gb=int(selected_traffic) if has_traffic_option and include_traffic_effective and selected_traffic is not None else None,
|
||||
device_options=[
|
||||
AccountKeyAddonOptionResponse(
|
||||
value=int(val),
|
||||
label=(
|
||||
"Безлимит устройств"
|
||||
if int(val) <= 0
|
||||
else (f"+{int(val)} устройств" if pack_mode else f"{int(val)} устройств")
|
||||
),
|
||||
)
|
||||
for val in device_options
|
||||
],
|
||||
traffic_options=[
|
||||
AccountKeyAddonOptionResponse(
|
||||
value=int(val),
|
||||
label=(
|
||||
"Безлимит трафика"
|
||||
if int(val) <= 0
|
||||
else (f"+{int(val)} ГБ" if pack_mode else f"{int(val)} ГБ")
|
||||
),
|
||||
)
|
||||
for val in traffic_options
|
||||
],
|
||||
total_price_rub=int(total_price_rub),
|
||||
extra_price_rub=int(max(0, extra_price_rub)),
|
||||
discount_rub=int(discount_rub),
|
||||
final_price_rub=int(max(0, final_extra_price_rub)),
|
||||
applied_coupon_code=applied_coupon_code,
|
||||
balance_rub=float(await get_balance(session, int(billing_user_id))),
|
||||
)
|
||||
|
||||
|
||||
@user_router.post("/{client_id}/apply-addons", response_model=AccountKeyApplyAddonsResponse)
|
||||
async def user_key_apply_addons(
|
||||
client_id: str,
|
||||
body: AccountKeyAddonsPreviewRequest,
|
||||
request: Request,
|
||||
force_web: bool = Query(False),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
actions = _key_actions_config()
|
||||
if not force_web and not actions.addons_enabled:
|
||||
raise HTTPException(status_code=403, detail="Доп. опции отключены в настройках")
|
||||
billing_user_id = await _resolve_billing_user_id(request, identity, session)
|
||||
db_key = (
|
||||
await session.execute(
|
||||
select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if db_key is None:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
tariff_id = getattr(db_key, "tariff_id", None)
|
||||
if not tariff_id:
|
||||
raise HTTPException(status_code=400, detail="Для подписки не назначен тариф")
|
||||
tariff = await get_tariff_by_id(session, int(tariff_id))
|
||||
if not tariff:
|
||||
raise HTTPException(status_code=404, detail="Тариф не найден")
|
||||
key_details = await get_key_details(session, str(getattr(db_key, "email", "") or ""))
|
||||
if not key_details:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
(
|
||||
_tariff_name,
|
||||
_subgroup_title,
|
||||
_traffic_limit_gb,
|
||||
_device_limit,
|
||||
_panel,
|
||||
is_tariff_configurable,
|
||||
addons_devices_enabled,
|
||||
addons_traffic_enabled,
|
||||
) = await get_key_tariff_addons_state(session=session, key_record=key_details, db_key=db_key)
|
||||
if not is_tariff_configurable:
|
||||
raise HTTPException(status_code=400, detail="Тариф не поддерживает доп. опции")
|
||||
cfg = normalize_tariff_config(tariff)
|
||||
raw_device_options = cfg.get("device_options") or tariff.get("device_options") or []
|
||||
raw_traffic_options = cfg.get("traffic_options_gb") or tariff.get("traffic_options_gb") or []
|
||||
device_options: list[int] = []
|
||||
for value in raw_device_options:
|
||||
try:
|
||||
device_options.append(int(value))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
traffic_options: list[int] = []
|
||||
for value in raw_traffic_options:
|
||||
try:
|
||||
traffic_options.append(int(value))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
device_options = sorted(set(device_options), key=lambda val: (int(val == 0), val))
|
||||
traffic_options = sorted(set(traffic_options), key=lambda val: (int(val == 0), val))
|
||||
has_device_option = bool(device_options) and bool(addons_devices_enabled)
|
||||
has_traffic_option = bool(traffic_options) and bool(addons_traffic_enabled)
|
||||
pack_devices, pack_traffic, pack_mode = get_pack_flags()
|
||||
if pack_mode:
|
||||
has_device_option = has_device_option and bool(pack_devices)
|
||||
has_traffic_option = has_traffic_option and bool(pack_traffic)
|
||||
if not has_device_option:
|
||||
device_options = []
|
||||
if not has_traffic_option:
|
||||
traffic_options = []
|
||||
if not has_device_option and not has_traffic_option:
|
||||
raise HTTPException(status_code=400, detail="Доп. опции для этой подписки недоступны")
|
||||
selected_device_limit_db = key_details.get("selected_device_limit")
|
||||
selected_traffic_limit_db = key_details.get("selected_traffic_limit")
|
||||
current_device_limit_db = key_details.get("current_device_limit")
|
||||
current_traffic_limit_db = key_details.get("current_traffic_limit")
|
||||
base_devices = tariff.get("device_limit")
|
||||
base_devices = int(base_devices) if base_devices is not None else None
|
||||
base_traffic_bytes = tariff.get("traffic_limit")
|
||||
base_traffic_gb_from_tariff = int(base_traffic_bytes / GB) if base_traffic_bytes else None
|
||||
current_device_limit = (
|
||||
int(current_device_limit_db)
|
||||
if current_device_limit_db is not None
|
||||
else (int(selected_device_limit_db) if selected_device_limit_db is not None else base_devices)
|
||||
)
|
||||
current_traffic_gb = (
|
||||
int(current_traffic_limit_db)
|
||||
if current_traffic_limit_db is not None
|
||||
else (int(selected_traffic_limit_db) if selected_traffic_limit_db is not None else base_traffic_gb_from_tariff)
|
||||
)
|
||||
if pack_mode and current_device_limit is not None and int(current_device_limit) == 0:
|
||||
has_device_option = False
|
||||
device_options = []
|
||||
if pack_mode and current_traffic_gb is not None and int(current_traffic_gb) == 0:
|
||||
has_traffic_option = False
|
||||
traffic_options = []
|
||||
if not has_device_option and not has_traffic_option:
|
||||
raise HTTPException(status_code=400, detail="Доп. опции для этой подписки недоступны")
|
||||
if pack_mode:
|
||||
include_device_effective = (
|
||||
bool(body.include_device) if body.include_device is not None else body.selected_device_limit is not None
|
||||
)
|
||||
include_traffic_effective = (
|
||||
bool(body.include_traffic) if body.include_traffic is not None else body.selected_traffic_gb is not None
|
||||
)
|
||||
selected_device = (
|
||||
body.selected_device_limit
|
||||
if body.selected_device_limit is not None
|
||||
else None
|
||||
)
|
||||
selected_traffic = (
|
||||
body.selected_traffic_gb
|
||||
if body.selected_traffic_gb is not None
|
||||
else None
|
||||
)
|
||||
else:
|
||||
include_device_effective = has_device_option
|
||||
include_traffic_effective = has_traffic_option
|
||||
selected_device = body.selected_device_limit if body.selected_device_limit is not None else current_device_limit
|
||||
selected_traffic = body.selected_traffic_gb if body.selected_traffic_gb is not None else current_traffic_gb
|
||||
if has_device_option and include_device_effective and selected_device is not None and int(selected_device) not in device_options:
|
||||
raise HTTPException(status_code=400, detail="Выбранный пакет устройств недоступен")
|
||||
if has_traffic_option and include_traffic_effective and selected_traffic is not None and int(selected_traffic) not in traffic_options:
|
||||
raise HTTPException(status_code=400, detail="Выбранный пакет трафика недоступен")
|
||||
current_devices_for_price = int(current_device_limit) if current_device_limit is not None else None
|
||||
current_traffic_for_price = int(current_traffic_gb) if current_traffic_gb is not None else None
|
||||
base_price_for_current = int(
|
||||
calculate_config_price(
|
||||
tariff=tariff,
|
||||
selected_device_limit=current_devices_for_price,
|
||||
selected_traffic_gb=current_traffic_for_price,
|
||||
)
|
||||
)
|
||||
total_price_after_purchase = base_price_for_current
|
||||
if pack_mode:
|
||||
diff_full = int(
|
||||
calc_pack_full_price_rub(
|
||||
tariff=tariff,
|
||||
has_device_option=bool(has_device_option and include_device_effective),
|
||||
has_traffic_option=bool(has_traffic_option and include_traffic_effective),
|
||||
selected_devices=int(selected_device) if has_device_option and include_device_effective and selected_device is not None else None,
|
||||
selected_traffic_gb=int(selected_traffic) if has_traffic_option and include_traffic_effective and selected_traffic is not None else None,
|
||||
)
|
||||
)
|
||||
recalc_enabled = bool(
|
||||
MODES_CONFIG.get(
|
||||
"KEY_ADDONS_RECALC_PRICE",
|
||||
TARIFFS_CONFIG.get("KEY_ADDONS_RECALC_PRICE", False),
|
||||
)
|
||||
)
|
||||
if recalc_enabled:
|
||||
remaining_seconds, total_seconds = calc_remaining_ratio_seconds(
|
||||
key_details.get("expiry_time"),
|
||||
tariff,
|
||||
)
|
||||
extra_price_rub = int((diff_full * remaining_seconds + total_seconds - 1) // total_seconds)
|
||||
else:
|
||||
extra_price_rub = diff_full
|
||||
total_price_after_purchase = int(base_price_for_current + diff_full)
|
||||
else:
|
||||
selected_total_price = int(
|
||||
calculate_config_price(
|
||||
tariff=tariff,
|
||||
selected_device_limit=int(selected_device) if has_device_option and selected_device is not None else None,
|
||||
selected_traffic_gb=int(selected_traffic) if has_traffic_option and selected_traffic is not None else None,
|
||||
)
|
||||
)
|
||||
extra_price_rub = int(max(0, selected_total_price - base_price_for_current))
|
||||
total_price_after_purchase = selected_total_price
|
||||
allow_downgrade = bool(TARIFFS_CONFIG.get("ALLOW_DOWNGRADE", True))
|
||||
device_downgrade = (
|
||||
allow_downgrade
|
||||
and has_device_option
|
||||
and current_device_limit is not None
|
||||
and selected_device is not None
|
||||
and not is_not_downgrade(current_device_limit, selected_device)
|
||||
)
|
||||
traffic_downgrade = (
|
||||
allow_downgrade
|
||||
and has_traffic_option
|
||||
and current_traffic_gb is not None
|
||||
and selected_traffic is not None
|
||||
and not is_not_downgrade(current_traffic_gb, selected_traffic)
|
||||
)
|
||||
if device_downgrade or traffic_downgrade:
|
||||
raise HTTPException(status_code=400, detail="Снижение параметров через сайт пока не поддерживается")
|
||||
final_extra_price_rub, discount_rub, coupon_id, applied_coupon_code = await resolve_percent_coupon_pricing(
|
||||
session=session,
|
||||
billing_user_id=int(billing_user_id),
|
||||
base_price_rub=int(max(0, extra_price_rub)),
|
||||
coupon_code=body.coupon_code,
|
||||
)
|
||||
balance = float(await get_balance(session, int(billing_user_id)))
|
||||
required_amount = int(max(0, ceil(float(final_extra_price_rub) - balance)))
|
||||
if extra_price_rub <= 0:
|
||||
return AccountKeyApplyAddonsResponse(
|
||||
ok=True,
|
||||
message="Доплата не требуется",
|
||||
client_id=str(getattr(db_key, "client_id", "") or ""),
|
||||
tariff_id=int(tariff_id),
|
||||
total_price_rub=int(total_price_after_purchase),
|
||||
extra_price_rub=0,
|
||||
discount_rub=0,
|
||||
final_price_rub=0,
|
||||
applied_coupon_code=None,
|
||||
charged_rub=0,
|
||||
balance_rub=balance,
|
||||
)
|
||||
expiry_time = int(getattr(db_key, "expiry_time", 0) or 0)
|
||||
email = str(getattr(db_key, "email", "") or "")
|
||||
server_id = str(getattr(db_key, "server_id", "") or "")
|
||||
if not email or not server_id:
|
||||
raise HTTPException(status_code=400, detail="Некорректные данные подписки")
|
||||
if required_amount > 0:
|
||||
provider_id = str(body.provider_id or _resolve_default_web_payment_provider() or "").strip().upper()
|
||||
if not provider_id:
|
||||
raise HTTPException(status_code=503, detail="Нет доступных провайдеров оплаты")
|
||||
base_url = _resolve_public_base_url(request)
|
||||
success_url = validate_redirect_url(str(body.success_url or ""), f"{base_url}/payment-success")
|
||||
failure_url = validate_redirect_url(str(body.failure_url or ""), f"{base_url}/payment-failure")
|
||||
payment_request = PaymentLinkRequest(
|
||||
legacy_user_ref=int(billing_user_id),
|
||||
amount=required_amount,
|
||||
currency="RUB",
|
||||
provider_id=provider_id,
|
||||
success_url=success_url,
|
||||
failure_url=failure_url,
|
||||
metadata={
|
||||
"payment_flow": "key_addons",
|
||||
"tariff_id": int(tariff_id),
|
||||
"email": email,
|
||||
"selected_device_limit": int(selected_device) if has_device_option and include_device_effective and selected_device is not None else None,
|
||||
"selected_traffic_gb": int(selected_traffic) if has_traffic_option and include_traffic_effective and selected_traffic is not None else None,
|
||||
"current_device_limit": int(current_device_limit) if current_device_limit is not None else None,
|
||||
"current_traffic_gb": int(current_traffic_gb) if current_traffic_gb is not None else None,
|
||||
"original_price": int(base_price_for_current),
|
||||
"base_price_rub": int(max(0, extra_price_rub)),
|
||||
"discount_rub": int(discount_rub),
|
||||
"applied_coupon_code": applied_coupon_code,
|
||||
"coupon_id": int(coupon_id) if coupon_id is not None else None,
|
||||
},
|
||||
)
|
||||
payment_result = await create_payment_link(session, payment_request)
|
||||
if not payment_result.success or not payment_result.payment_url or not payment_result.payment_id:
|
||||
raise HTTPException(status_code=400, detail=payment_result.error or "Не удалось создать ссылку оплаты")
|
||||
await create_temporary_data(
|
||||
session,
|
||||
int(billing_user_id),
|
||||
"waiting_for_addons_payment",
|
||||
{
|
||||
"tariff_id": int(tariff_id),
|
||||
"email": email,
|
||||
"required_amount": int(required_amount),
|
||||
"selected_device_limit": int(selected_device) if has_device_option and include_device_effective and selected_device is not None else None,
|
||||
"selected_traffic_gb": int(selected_traffic) if has_traffic_option and include_traffic_effective and selected_traffic is not None else None,
|
||||
"current_device_limit": int(current_device_limit) if current_device_limit is not None else None,
|
||||
"current_traffic_gb": int(current_traffic_gb) if current_traffic_gb is not None else None,
|
||||
"original_price": int(base_price_for_current),
|
||||
"base_price_rub": int(max(0, extra_price_rub)),
|
||||
"discount_rub": int(discount_rub),
|
||||
"applied_coupon_code": applied_coupon_code,
|
||||
"coupon_id": int(coupon_id) if coupon_id is not None else None,
|
||||
},
|
||||
)
|
||||
return AccountKeyApplyAddonsResponse(
|
||||
ok=True,
|
||||
message="Требуется оплата для применения доп. опций",
|
||||
client_id=str(getattr(db_key, "client_id", "") or ""),
|
||||
tariff_id=int(tariff_id),
|
||||
total_price_rub=int(total_price_after_purchase),
|
||||
extra_price_rub=int(extra_price_rub),
|
||||
discount_rub=int(discount_rub),
|
||||
final_price_rub=int(final_extra_price_rub),
|
||||
applied_coupon_code=applied_coupon_code,
|
||||
charged_rub=0,
|
||||
balance_rub=balance,
|
||||
payment_required=True,
|
||||
required_amount_rub=required_amount,
|
||||
payment_id=payment_result.payment_id,
|
||||
payment_url=payment_result.payment_url,
|
||||
)
|
||||
target_subgroup = tariff.get("subgroup_title")
|
||||
current_subgroup = None
|
||||
current_tariff_id = key_details.get("tariff_id")
|
||||
if current_tariff_id:
|
||||
current_tariff = await get_tariff_by_id(session, int(current_tariff_id))
|
||||
if current_tariff:
|
||||
current_subgroup = current_tariff.get("subgroup_title")
|
||||
if pack_mode:
|
||||
device_limit_effective_current, traffic_limit_bytes_effective_current = await get_effective_limits_for_key(
|
||||
session=session,
|
||||
tariff_id=int(tariff_id),
|
||||
selected_device_limit=int(current_device_limit) if current_device_limit is not None else None,
|
||||
selected_traffic_gb=int(current_traffic_gb) if current_traffic_gb is not None else None,
|
||||
)
|
||||
traffic_limit_gb_effective_current = (
|
||||
int(traffic_limit_bytes_effective_current / GB) if traffic_limit_bytes_effective_current else 0
|
||||
)
|
||||
new_device_limit_effective = device_limit_effective_current
|
||||
new_traffic_limit_gb_effective = traffic_limit_gb_effective_current
|
||||
if has_device_option and include_device_effective and selected_device is not None:
|
||||
pack_devices_val = int(selected_device)
|
||||
if pack_devices_val <= 0 or (
|
||||
new_device_limit_effective is not None and int(new_device_limit_effective) <= 0
|
||||
):
|
||||
new_device_limit_effective = 0
|
||||
else:
|
||||
if new_device_limit_effective is None:
|
||||
new_device_limit_effective = pack_devices_val
|
||||
else:
|
||||
new_device_limit_effective = int(new_device_limit_effective) + pack_devices_val
|
||||
if has_traffic_option and include_traffic_effective and selected_traffic is not None:
|
||||
pack_traffic_val = int(selected_traffic)
|
||||
if pack_traffic_val <= 0 or int(new_traffic_limit_gb_effective) <= 0:
|
||||
new_traffic_limit_gb_effective = 0
|
||||
else:
|
||||
new_traffic_limit_gb_effective = int(new_traffic_limit_gb_effective) + pack_traffic_val
|
||||
await renew_key_in_cluster(
|
||||
cluster_id=server_id,
|
||||
email=email,
|
||||
client_id=str(getattr(db_key, "client_id", "") or ""),
|
||||
new_expiry_time=expiry_time,
|
||||
total_gb=int(new_traffic_limit_gb_effective),
|
||||
session=session,
|
||||
hwid_device_limit=int(new_device_limit_effective) if new_device_limit_effective is not None else 0,
|
||||
reset_traffic=False,
|
||||
target_subgroup=target_subgroup,
|
||||
old_subgroup=current_subgroup,
|
||||
plan=int(tariff_id),
|
||||
)
|
||||
await save_key_config_with_mode(
|
||||
session=session,
|
||||
email=email,
|
||||
selected_devices=int(new_device_limit_effective) if new_device_limit_effective is not None else None,
|
||||
selected_traffic_gb=int(new_traffic_limit_gb_effective) if new_traffic_limit_gb_effective is not None else None,
|
||||
total_price=int(total_price_after_purchase),
|
||||
has_device_choice=bool(has_device_option and include_device_effective),
|
||||
has_traffic_choice=bool(has_traffic_option and include_traffic_effective),
|
||||
config_mode="pack",
|
||||
)
|
||||
else:
|
||||
selected_device_for_effective = int(selected_device) if has_device_option and include_device_effective and selected_device is not None else None
|
||||
selected_traffic_for_effective = int(selected_traffic) if has_traffic_option and include_traffic_effective and selected_traffic is not None else 0
|
||||
device_limit_effective_new, traffic_limit_bytes_effective_new = await get_effective_limits_for_key(
|
||||
session=session,
|
||||
tariff_id=int(tariff_id),
|
||||
selected_device_limit=selected_device_for_effective,
|
||||
selected_traffic_gb=selected_traffic_for_effective,
|
||||
)
|
||||
traffic_limit_gb_effective = int(traffic_limit_bytes_effective_new / GB) if traffic_limit_bytes_effective_new else 0
|
||||
await renew_key_in_cluster(
|
||||
cluster_id=server_id,
|
||||
email=email,
|
||||
client_id=str(getattr(db_key, "client_id", "") or ""),
|
||||
new_expiry_time=expiry_time,
|
||||
total_gb=int(traffic_limit_gb_effective),
|
||||
session=session,
|
||||
hwid_device_limit=int(device_limit_effective_new) if device_limit_effective_new is not None else 0,
|
||||
reset_traffic=False,
|
||||
target_subgroup=target_subgroup,
|
||||
old_subgroup=current_subgroup,
|
||||
plan=int(tariff_id),
|
||||
)
|
||||
await save_key_config_with_mode(
|
||||
session=session,
|
||||
email=email,
|
||||
selected_devices=int(selected_device) if has_device_option and include_device_effective and selected_device is not None else None,
|
||||
selected_traffic_gb=int(selected_traffic) if has_traffic_option and include_traffic_effective and selected_traffic is not None else None,
|
||||
total_price=int(total_price_after_purchase),
|
||||
has_device_choice=bool(has_device_option and include_device_effective),
|
||||
has_traffic_choice=bool(has_traffic_option and include_traffic_effective),
|
||||
config_mode="addon",
|
||||
)
|
||||
await update_balance(session, int(billing_user_id), -int(final_extra_price_rub))
|
||||
if coupon_id is not None:
|
||||
await mark_coupon_used(session, int(coupon_id), int(billing_user_id))
|
||||
await session.commit()
|
||||
return AccountKeyApplyAddonsResponse(
|
||||
ok=True,
|
||||
message="Доп. опции применены",
|
||||
client_id=str(getattr(db_key, "client_id", "") or ""),
|
||||
tariff_id=int(tariff_id),
|
||||
total_price_rub=int(total_price_after_purchase),
|
||||
extra_price_rub=int(extra_price_rub),
|
||||
discount_rub=int(discount_rub),
|
||||
final_price_rub=int(final_extra_price_rub),
|
||||
applied_coupon_code=applied_coupon_code,
|
||||
charged_rub=int(final_extra_price_rub),
|
||||
balance_rub=float(await get_balance(session, int(billing_user_id))),
|
||||
)
|
||||
@@ -0,0 +1,249 @@
|
||||
"""User-facing key endpoints (/api/keys/*).
|
||||
|
||||
Регистрирует эндпоинты на ``user_router`` из ``_common``. Импорт этого модуля
|
||||
из ``__init__.py`` запускает регистрацию декораторов.
|
||||
"""
|
||||
|
||||
from .._common import * # noqa: F401,F403 — подтягиваем все имена для endpoints
|
||||
from .._common import (
|
||||
_key_actions_config,
|
||||
_resolve_available_location_servers,
|
||||
_resolve_billing_user_id,
|
||||
_resolve_default_web_payment_provider,
|
||||
_resolve_public_base_url,
|
||||
_normalize_expiry_ms,
|
||||
router,
|
||||
user_router,
|
||||
)
|
||||
|
||||
|
||||
@user_router.get("", response_model=list[AccountKeyResponse])
|
||||
async def user_keys(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
billing_user_id = await _resolve_billing_user_id(request, identity, session)
|
||||
keys = await get_keys(session, billing_user_id)
|
||||
result: list[AccountKeyResponse] = []
|
||||
for key in keys:
|
||||
key_actions = AccountKeyActionsAvailability()
|
||||
try:
|
||||
key_ref = str(getattr(key, "client_id", "") or getattr(key, "email", "") or "")
|
||||
_, markup, _ = await build_key_view_payload(session, int(billing_user_id), key_ref)
|
||||
key_actions = _extract_key_actions_from_markup(markup)
|
||||
except Exception:
|
||||
key_actions = AccountKeyActionsAvailability()
|
||||
result.append(
|
||||
AccountKeyResponse(
|
||||
email=str(getattr(key, "email", "") or ""),
|
||||
alias=getattr(key, "alias", None),
|
||||
client_id=str(getattr(key, "client_id", "") or ""),
|
||||
tariff_id=getattr(key, "tariff_id", None),
|
||||
server_id=str(getattr(key, "server_id", "") or ""),
|
||||
created_at=int(getattr(key, "created_at", 0) or 0),
|
||||
expiry_time=int(getattr(key, "expiry_time", 0) or 0),
|
||||
key=getattr(key, "key", None),
|
||||
remnawave_link=getattr(key, "remnawave_link", None),
|
||||
is_frozen=bool(getattr(key, "is_frozen", False)),
|
||||
actions=key_actions,
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@user_router.get("/actions-config", response_model=AccountKeyActionsConfigResponse)
|
||||
async def user_keys_actions_config(
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
_ = identity
|
||||
return _key_actions_config()
|
||||
|
||||
|
||||
@user_router.get("/{client_id}/details", response_model=AccountKeyDetailsResponse)
|
||||
async def user_key_details(
|
||||
client_id: str,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
billing_user_id = await _resolve_billing_user_id(request, identity, session)
|
||||
db_key = (
|
||||
await session.execute(
|
||||
select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if db_key is None:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
key_details = await get_key_details(session, str(getattr(db_key, "email", "") or ""))
|
||||
if not key_details:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
tariff_name = ""
|
||||
subgroup_title = ""
|
||||
traffic_limit_gb = 0
|
||||
device_limit = 0
|
||||
is_tariff_configurable = False
|
||||
addons_devices_enabled = False
|
||||
addons_traffic_enabled = False
|
||||
(
|
||||
tariff_name,
|
||||
subgroup_title,
|
||||
traffic_limit_gb,
|
||||
device_limit,
|
||||
_,
|
||||
is_tariff_configurable,
|
||||
addons_devices_enabled,
|
||||
addons_traffic_enabled,
|
||||
) = await get_key_tariff_addons_state(
|
||||
session=session,
|
||||
key_record=key_details,
|
||||
db_key=db_key,
|
||||
)
|
||||
connected_devices = 0
|
||||
used_traffic_gb = None
|
||||
try:
|
||||
profile = await get_remnawave_profile(
|
||||
session,
|
||||
str(getattr(db_key, "server_id", "") or ""),
|
||||
client_id,
|
||||
fallback_any=True,
|
||||
)
|
||||
if profile:
|
||||
connected_devices = int(profile.get("hwid_count") or 0)
|
||||
used_raw = profile.get("used_gb")
|
||||
used_traffic_gb = float(used_raw) if used_raw is not None else None
|
||||
traffic_limit_bytes_actual = profile.get("traffic_limit_bytes")
|
||||
if traffic_limit_bytes_actual is not None:
|
||||
try:
|
||||
traffic_limit_bytes_actual = int(traffic_limit_bytes_actual)
|
||||
traffic_limit_gb = int(traffic_limit_bytes_actual / GB) if traffic_limit_bytes_actual > 0 else 0
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
except Exception:
|
||||
connected_devices = 0
|
||||
used_traffic_gb = None
|
||||
return AccountKeyDetailsResponse(
|
||||
client_id=str(getattr(db_key, "client_id", "") or ""),
|
||||
email=str(getattr(db_key, "email", "") or ""),
|
||||
alias=getattr(db_key, "alias", None),
|
||||
expiry_time=int(getattr(db_key, "expiry_time", 0) or 0),
|
||||
is_frozen=bool(getattr(db_key, "is_frozen", False)),
|
||||
tariff_name=str(tariff_name or ""),
|
||||
subgroup_title=str(subgroup_title or ""),
|
||||
traffic_limit_gb=int(traffic_limit_gb or 0),
|
||||
used_traffic_gb=used_traffic_gb,
|
||||
device_limit=int(device_limit or 0),
|
||||
connected_devices=int(connected_devices or 0),
|
||||
is_tariff_configurable=bool(is_tariff_configurable),
|
||||
addons_devices_enabled=bool(addons_devices_enabled),
|
||||
addons_traffic_enabled=bool(addons_traffic_enabled),
|
||||
)
|
||||
|
||||
|
||||
@user_router.get("/{client_id}/qr", response_model=AccountKeyQrResponse)
|
||||
async def user_key_qr(
|
||||
client_id: str,
|
||||
request: Request,
|
||||
force_web: bool = Query(False),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
actions = _key_actions_config()
|
||||
if not force_web and not actions.qr_enabled:
|
||||
raise HTTPException(status_code=403, detail="QR для подписок отключен в настройках")
|
||||
billing_user_id = await _resolve_billing_user_id(request, identity, session)
|
||||
db_key = (
|
||||
await session.execute(
|
||||
select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if db_key is None:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
qr_data = str(getattr(db_key, "key", "") or "").strip() or str(getattr(db_key, "remnawave_link", "") or "").strip()
|
||||
if not qr_data:
|
||||
raise HTTPException(status_code=400, detail="Ссылка для подключения отсутствует")
|
||||
qr = qrcode.QRCode(version=1, box_size=10, border=4)
|
||||
qr.add_data(qr_data)
|
||||
qr.make(fit=True)
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
buffer = BytesIO()
|
||||
img.save(buffer, format="PNG")
|
||||
image_data = b64encode(buffer.getvalue()).decode("ascii")
|
||||
return AccountKeyQrResponse(
|
||||
ok=True,
|
||||
message="QR-код готов",
|
||||
link=qr_data,
|
||||
image_data_url=f"data:image/png;base64,{image_data}",
|
||||
)
|
||||
|
||||
|
||||
@user_router.patch("/{client_id}/alias", response_model=AccountKeyResponse)
|
||||
async def user_key_update_alias(
|
||||
client_id: str,
|
||||
body: AccountKeyAliasUpdateRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
alias = str(body.alias or "").strip()
|
||||
if not alias:
|
||||
raise HTTPException(status_code=400, detail="Укажите alias")
|
||||
if len(alias) > 10:
|
||||
raise HTTPException(status_code=400, detail="Alias должен быть не длиннее 10 символов")
|
||||
if not re.match(r"^[a-zA-Zа-яА-ЯёЁ0-9@._-]+$", alias):
|
||||
raise HTTPException(status_code=400, detail="Alias содержит недопустимые символы")
|
||||
billing_user_id = await _resolve_billing_user_id(request, identity, session)
|
||||
db_key = (
|
||||
await session.execute(
|
||||
select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if db_key is None:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
db_key.alias = alias
|
||||
await session.commit()
|
||||
return AccountKeyResponse(
|
||||
email=str(getattr(db_key, "email", "") or ""),
|
||||
alias=getattr(db_key, "alias", None),
|
||||
client_id=str(getattr(db_key, "client_id", "") or ""),
|
||||
tariff_id=getattr(db_key, "tariff_id", None),
|
||||
server_id=str(getattr(db_key, "server_id", "") or ""),
|
||||
created_at=int(getattr(db_key, "created_at", 0) or 0),
|
||||
expiry_time=int(getattr(db_key, "expiry_time", 0) or 0),
|
||||
key=getattr(db_key, "key", None),
|
||||
remnawave_link=getattr(db_key, "remnawave_link", None),
|
||||
is_frozen=bool(getattr(db_key, "is_frozen", False)),
|
||||
)
|
||||
|
||||
|
||||
@user_router.delete("/{client_id}", response_model=AccountKeyActionResponse)
|
||||
async def user_key_delete(
|
||||
client_id: str,
|
||||
request: Request,
|
||||
force_web: bool = Query(False),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
actions = _key_actions_config()
|
||||
if not force_web and not actions.delete_enabled:
|
||||
raise HTTPException(status_code=403, detail="Удаление подписки отключено в настройках")
|
||||
billing_user_id = await _resolve_billing_user_id(request, identity, session)
|
||||
db_key = (
|
||||
await session.execute(
|
||||
select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if db_key is None:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
cluster_id = str(getattr(db_key, "server_id", "") or "")
|
||||
email = str(getattr(db_key, "email", "") or "")
|
||||
if cluster_id and email:
|
||||
await delete_key_from_cluster(
|
||||
cluster_id=cluster_id,
|
||||
email=email,
|
||||
client_id=client_id,
|
||||
session=session,
|
||||
)
|
||||
await session.delete(db_key)
|
||||
await session.commit()
|
||||
return AccountKeyActionResponse(ok=True, message="Подписка удалена")
|
||||
@@ -0,0 +1,75 @@
|
||||
"""User-facing key endpoints (/api/keys/*).
|
||||
|
||||
Регистрирует эндпоинты на ``user_router`` из ``_common``. Импорт этого модуля
|
||||
из ``__init__.py`` запускает регистрацию декораторов.
|
||||
"""
|
||||
|
||||
from .._common import * # noqa: F401,F403 — подтягиваем все имена для endpoints
|
||||
from .._common import (
|
||||
_key_actions_config,
|
||||
_resolve_available_location_servers,
|
||||
_resolve_billing_user_id,
|
||||
_resolve_default_web_payment_provider,
|
||||
_resolve_public_base_url,
|
||||
_normalize_expiry_ms,
|
||||
router,
|
||||
user_router,
|
||||
)
|
||||
|
||||
|
||||
@user_router.post("/{client_id}/reset-hwid", response_model=AccountKeyResetHwidResponse)
|
||||
async def user_key_reset_hwid(
|
||||
client_id: str,
|
||||
request: Request,
|
||||
force_web: bool = Query(False),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
actions = _key_actions_config()
|
||||
if not force_web and not actions.hwid_reset_enabled:
|
||||
raise HTTPException(status_code=403, detail="Сброс устройств отключен в настройках")
|
||||
billing_user_id = await _resolve_billing_user_id(request, identity, session)
|
||||
db_key = (
|
||||
await session.execute(
|
||||
select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if db_key is None:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
server_id = str(getattr(db_key, "server_id", "") or "")
|
||||
if not server_id:
|
||||
raise HTTPException(status_code=400, detail="У подписки не указан сервер")
|
||||
|
||||
async def _reset_devices(api):
|
||||
devices = await api.get_user_hwid_devices(client_id)
|
||||
if not devices:
|
||||
return 0, 0
|
||||
reset_local = 0
|
||||
for device in devices:
|
||||
hwid = device.get("hwid")
|
||||
if hwid and await api.delete_user_hwid_device(client_id, hwid):
|
||||
reset_local += 1
|
||||
return len(devices), reset_local
|
||||
|
||||
reset_result = await with_remnawave_api(
|
||||
session,
|
||||
server_id,
|
||||
_reset_devices,
|
||||
fallback_any=True,
|
||||
timeout_sec=12.0,
|
||||
)
|
||||
if reset_result is None:
|
||||
raise HTTPException(status_code=502, detail="Не удалось выполнить сброс устройств")
|
||||
total_devices, reset_devices = reset_result
|
||||
await invalidate_remnawave_profile(
|
||||
session,
|
||||
server_id,
|
||||
str(client_id),
|
||||
fallback_any=True,
|
||||
)
|
||||
return AccountKeyResetHwidResponse(
|
||||
ok=True,
|
||||
message="Устройства сброшены" if total_devices > 0 else "Устройства не были привязаны",
|
||||
total_devices=int(total_devices),
|
||||
reset_devices=int(reset_devices),
|
||||
)
|
||||
@@ -0,0 +1,230 @@
|
||||
"""User-facing key endpoints (/api/keys/*).
|
||||
|
||||
Регистрирует эндпоинты на ``user_router`` из ``_common``. Импорт этого модуля
|
||||
из ``__init__.py`` запускает регистрацию декораторов.
|
||||
"""
|
||||
|
||||
from .._common import * # noqa: F401,F403 — подтягиваем все имена для endpoints
|
||||
from .._common import (
|
||||
_key_actions_config,
|
||||
_resolve_available_location_servers,
|
||||
_resolve_billing_user_id,
|
||||
_resolve_default_web_payment_provider,
|
||||
_resolve_public_base_url,
|
||||
_normalize_expiry_ms,
|
||||
router,
|
||||
user_router,
|
||||
)
|
||||
|
||||
|
||||
@user_router.get("/{client_id}/locations", response_model=AccountKeyLocationsResponse)
|
||||
async def user_key_locations(
|
||||
client_id: str,
|
||||
request: Request,
|
||||
force_web: bool = Query(False),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
actions = _key_actions_config()
|
||||
if not force_web and not actions.country_change_enabled:
|
||||
raise HTTPException(status_code=403, detail="Смена локации отключена в настройках")
|
||||
billing_user_id = await _resolve_billing_user_id(request, identity, session)
|
||||
db_key = (
|
||||
await session.execute(
|
||||
select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if db_key is None:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
names = await _resolve_available_location_servers(session, db_key)
|
||||
return AccountKeyLocationsResponse(
|
||||
client_id=str(getattr(db_key, "client_id", "") or ""),
|
||||
current_server=str(getattr(db_key, "server_id", "") or ""),
|
||||
locations=[AccountKeyLocationOptionResponse(server_name=name) for name in names],
|
||||
)
|
||||
|
||||
|
||||
@user_router.post("/{client_id}/change-location", response_model=AccountKeyChangeLocationResponse)
|
||||
async def user_key_change_location(
|
||||
client_id: str,
|
||||
body: AccountKeyChangeLocationRequest,
|
||||
request: Request,
|
||||
force_web: bool = Query(False),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
actions = _key_actions_config()
|
||||
if not force_web and not actions.country_change_enabled:
|
||||
raise HTTPException(status_code=403, detail="Смена локации отключена в настройках")
|
||||
target_server = str(body.server_name or "").strip()
|
||||
if not target_server:
|
||||
raise HTTPException(status_code=400, detail="Укажите целевую локацию")
|
||||
billing_user_id = await _resolve_billing_user_id(request, identity, session)
|
||||
db_key = (
|
||||
await session.execute(
|
||||
select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if db_key is None:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
current_server = str(getattr(db_key, "server_id", "") or "")
|
||||
if not current_server:
|
||||
raise HTTPException(status_code=400, detail="У подписки не указан текущий сервер")
|
||||
if current_server == target_server:
|
||||
raise HTTPException(status_code=400, detail="Подписка уже в этой локации")
|
||||
available_names = await _resolve_available_location_servers(session, db_key)
|
||||
if target_server not in available_names:
|
||||
raise HTTPException(status_code=400, detail="Выбранная локация недоступна")
|
||||
email = str(getattr(db_key, "email", "") or "")
|
||||
if not email:
|
||||
raise HTTPException(status_code=400, detail="У подписки отсутствует email")
|
||||
key_details = await get_key_details(session, email)
|
||||
if not key_details:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
old_server_info = (
|
||||
await session.execute(select(Server).where(Server.server_name == current_server).limit(1))
|
||||
).scalar_one_or_none()
|
||||
if old_server_info:
|
||||
old_panel_type = str(getattr(old_server_info, "panel_type", "") or "").lower()
|
||||
try:
|
||||
if old_panel_type == "3x-ui":
|
||||
xui = await get_xui_instance(str(getattr(old_server_info, "api_url", "") or ""))
|
||||
await delete_client(
|
||||
xui,
|
||||
int(getattr(old_server_info, "inbound_id", 0) or 0),
|
||||
email,
|
||||
str(getattr(db_key, "client_id", "") or ""),
|
||||
)
|
||||
elif old_panel_type == "remnawave":
|
||||
remna_del = RemnawaveAPI(str(getattr(old_server_info, "api_url", "") or ""))
|
||||
if await remna_del.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
|
||||
await remna_del.delete_user(str(getattr(db_key, "client_id", "") or ""))
|
||||
except Exception:
|
||||
pass
|
||||
target_server_info = (
|
||||
await session.execute(select(Server).where(Server.server_name == target_server).limit(1))
|
||||
).scalar_one_or_none()
|
||||
if target_server_info is None:
|
||||
raise HTTPException(status_code=404, detail="Целевая локация не найдена")
|
||||
tariff_id = getattr(db_key, "tariff_id", None)
|
||||
tariff = await get_tariff_by_id(session, int(tariff_id)) if tariff_id else None
|
||||
need_vless_key = bool(tariff.get("vless")) if tariff else False
|
||||
external_squad_uuid = (tariff.get("external_squad") if tariff else None) or None
|
||||
selected_traffic_gb = getattr(db_key, "selected_traffic_limit", None)
|
||||
selected_device_limit = getattr(db_key, "selected_device_limit", None)
|
||||
if selected_traffic_gb is not None:
|
||||
traffic_limit_bytes = int(selected_traffic_gb) * GB
|
||||
else:
|
||||
raw_traffic_limit = int(tariff.get("traffic_limit") or 0) if tariff else 0
|
||||
traffic_limit_bytes = raw_traffic_limit * GB if raw_traffic_limit > 0 else 0
|
||||
if selected_device_limit is not None:
|
||||
device_limit = int(selected_device_limit)
|
||||
else:
|
||||
device_limit = int(tariff.get("device_limit") or 0) if tariff else 0
|
||||
key_client_id = str(getattr(db_key, "client_id", "") or "")
|
||||
expiry_timestamp = int(getattr(db_key, "expiry_time", 0) or 0)
|
||||
target_cluster_info = await check_server_name_by_cluster(session, target_server)
|
||||
target_cluster_name = str((target_cluster_info or {}).get("cluster_name") or "")
|
||||
full_remnawave_cluster = (
|
||||
await is_full_remnawave_cluster(target_cluster_name, session) if target_cluster_name else False
|
||||
)
|
||||
panel_type = str(getattr(target_server_info, "panel_type", "") or "").lower()
|
||||
remnawave_link = None
|
||||
if panel_type == "remnawave" or full_remnawave_cluster:
|
||||
remna = RemnawaveAPI(str(getattr(target_server_info, "api_url", "") or ""))
|
||||
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
|
||||
raise HTTPException(status_code=502, detail="Не удалось авторизоваться в Remnawave")
|
||||
expire_at = datetime.utcfromtimestamp(expiry_timestamp / 1000).isoformat() + "Z"
|
||||
user_data: dict[str, Any] = {
|
||||
"username": email,
|
||||
"trafficLimitStrategy": "NO_RESET",
|
||||
"expireAt": expire_at,
|
||||
"telegramId": int(key_details.get("tg_id") or 0),
|
||||
"activeInternalSquads": [getattr(target_server_info, "inbound_id", None)],
|
||||
"uuid": key_client_id,
|
||||
}
|
||||
if traffic_limit_bytes > 0:
|
||||
user_data["trafficLimitBytes"] = traffic_limit_bytes
|
||||
if device_limit > 0:
|
||||
user_data["hwidDeviceLimit"] = device_limit
|
||||
if external_squad_uuid:
|
||||
user_data["externalSquadUuid"] = external_squad_uuid
|
||||
result = await remna.create_user(user_data)
|
||||
if not result:
|
||||
raise HTTPException(status_code=502, detail="Не удалось создать подписку в новой локации")
|
||||
key_client_id = str(result.get("uuid") or result.get("id") or key_client_id)
|
||||
if need_vless_key:
|
||||
try:
|
||||
remnawave_link = await get_vless_link_for_remnawave_by_username(remna, email, email)
|
||||
except Exception:
|
||||
remnawave_link = None
|
||||
if not remnawave_link:
|
||||
try:
|
||||
sub = await remna.get_subscription_by_username(email)
|
||||
except Exception:
|
||||
sub = None
|
||||
if sub:
|
||||
links = sub.get("links") or []
|
||||
remnawave_link = (
|
||||
next(
|
||||
(link for link in links if isinstance(link, str) and link.lower().startswith("vless://")),
|
||||
None,
|
||||
)
|
||||
if need_vless_key
|
||||
else None
|
||||
)
|
||||
if not remnawave_link:
|
||||
remnawave_link = sub.get("subscriptionUrl")
|
||||
if panel_type == "3x-ui":
|
||||
await create_client_on_server(
|
||||
{
|
||||
"api_url": str(getattr(target_server_info, "api_url", "") or ""),
|
||||
"inbound_id": getattr(target_server_info, "inbound_id", None),
|
||||
"server_name": str(getattr(target_server_info, "server_name", "") or ""),
|
||||
"panel_type": str(getattr(target_server_info, "panel_type", "") or ""),
|
||||
},
|
||||
int(key_details.get("tg_id") or 0),
|
||||
key_client_id,
|
||||
email,
|
||||
expiry_timestamp,
|
||||
asyncio.Semaphore(1),
|
||||
plan=int(tariff_id) if tariff_id else None,
|
||||
session=session,
|
||||
is_trial=False,
|
||||
total_traffic_limit_bytes=traffic_limit_bytes,
|
||||
device_limit_value=device_limit,
|
||||
)
|
||||
subgroup_code = tariff.get("subgroup_title") if tariff and tariff.get("subgroup_title") else None
|
||||
public_link = await make_aggregated_link(
|
||||
session=session,
|
||||
cluster_all=[
|
||||
{
|
||||
"server_name": str(getattr(target_server_info, "server_name", "") or ""),
|
||||
"api_url": str(getattr(target_server_info, "api_url", "") or ""),
|
||||
"panel_type": str(getattr(target_server_info, "panel_type", "") or ""),
|
||||
"inbound_id": getattr(target_server_info, "inbound_id", None),
|
||||
"enabled": True,
|
||||
"max_keys": getattr(target_server_info, "max_keys", None),
|
||||
}
|
||||
],
|
||||
cluster_id=target_cluster_name or target_server,
|
||||
email=email,
|
||||
client_id=key_client_id,
|
||||
tg_id=int(key_details.get("tg_id") or 0),
|
||||
subgroup_code=subgroup_code,
|
||||
remna_link_override=remnawave_link,
|
||||
plan=int(tariff_id) if tariff_id else None,
|
||||
)
|
||||
db_key.server_id = target_server
|
||||
db_key.client_id = key_client_id
|
||||
db_key.key = public_link if isinstance(public_link, str) and public_link.strip() else None
|
||||
db_key.remnawave_link = remnawave_link
|
||||
await session.commit()
|
||||
return AccountKeyChangeLocationResponse(
|
||||
ok=True,
|
||||
message="Локация успешно изменена",
|
||||
client_id=str(getattr(db_key, "client_id", "") or ""),
|
||||
server_id=str(getattr(db_key, "server_id", "") or ""),
|
||||
link=str(getattr(db_key, "key", "") or ""),
|
||||
remnawave_link=getattr(db_key, "remnawave_link", None),
|
||||
)
|
||||
@@ -0,0 +1,191 @@
|
||||
"""User-facing key endpoints (/api/keys/*).
|
||||
|
||||
Регистрирует эндпоинты на ``user_router`` из ``_common``. Импорт этого модуля
|
||||
из ``__init__.py`` запускает регистрацию декораторов.
|
||||
"""
|
||||
|
||||
from .._common import * # noqa: F401,F403 — подтягиваем все имена для endpoints
|
||||
from .._common import (
|
||||
_key_actions_config,
|
||||
_resolve_available_location_servers,
|
||||
_resolve_billing_user_id,
|
||||
_resolve_default_web_payment_provider,
|
||||
_resolve_public_base_url,
|
||||
_normalize_expiry_ms,
|
||||
router,
|
||||
user_router,
|
||||
)
|
||||
|
||||
|
||||
@user_router.post("/{client_id}/renew", response_model=AccountKeyRenewResponse)
|
||||
async def user_key_renew(
|
||||
client_id: str,
|
||||
body: AccountKeyRenewRequest,
|
||||
request: Request,
|
||||
force_web: bool = Query(False),
|
||||
preview: bool = Query(False),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
from services.errors import ServiceError
|
||||
from services.keys import (
|
||||
calculate_renewal_pricing,
|
||||
execute_renewal,
|
||||
normalize_expiry_ms as _svc_normalize_expiry,
|
||||
)
|
||||
|
||||
actions = _key_actions_config()
|
||||
if not force_web and not actions.renew_enabled:
|
||||
raise HTTPException(status_code=403, detail="Продление подписки отключено в настройках")
|
||||
billing_user_id = await _resolve_billing_user_id(request, identity, session)
|
||||
db_key = (
|
||||
await session.execute(
|
||||
select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if db_key is None:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
if bool(getattr(db_key, "is_frozen", False)):
|
||||
raise HTTPException(status_code=400, detail="Продление для замороженной подписки недоступно")
|
||||
tariff_id = getattr(db_key, "tariff_id", None)
|
||||
if not tariff_id:
|
||||
raise HTTPException(status_code=400, detail="Для подписки не назначен тариф")
|
||||
key_email = str(getattr(db_key, "email", "") or "")
|
||||
key_server_id = str(getattr(db_key, "server_id", "") or "")
|
||||
|
||||
try:
|
||||
pricing = await calculate_renewal_pricing(
|
||||
session=session,
|
||||
billing_user_id=int(billing_user_id),
|
||||
key_email=key_email,
|
||||
tariff_id=int(tariff_id),
|
||||
coupon_code=body.coupon_code,
|
||||
)
|
||||
except ServiceError as e:
|
||||
raise HTTPException(status_code=400, detail=e.message)
|
||||
|
||||
if preview:
|
||||
return AccountKeyRenewResponse(
|
||||
ok=True,
|
||||
message="Расчет обновлен",
|
||||
client_id=str(client_id),
|
||||
tariff_id=int(tariff_id),
|
||||
charged_rub=0,
|
||||
balance_rub=pricing.balance,
|
||||
base_price_rub=pricing.base_price_rub,
|
||||
discount_rub=pricing.discount_rub,
|
||||
final_price_rub=pricing.final_price_rub,
|
||||
applied_coupon_code=pricing.applied_coupon_code,
|
||||
payment_required=pricing.payment_required,
|
||||
required_amount_rub=pricing.required_amount,
|
||||
payment_id=None,
|
||||
payment_url=None,
|
||||
)
|
||||
if pricing.payment_required:
|
||||
provider_id = str(body.provider_id or _resolve_default_web_payment_provider() or "").strip().upper()
|
||||
if not provider_id:
|
||||
raise HTTPException(status_code=503, detail="Нет доступных провайдеров оплаты")
|
||||
base_url = _resolve_public_base_url(request)
|
||||
success_url = validate_redirect_url(str(body.success_url or ""), f"{base_url}/payment-success")
|
||||
failure_url = validate_redirect_url(str(body.failure_url or ""), f"{base_url}/payment-failure")
|
||||
payment_request = PaymentLinkRequest(
|
||||
legacy_user_ref=int(billing_user_id),
|
||||
amount=pricing.required_amount,
|
||||
currency="RUB",
|
||||
provider_id=provider_id,
|
||||
success_url=success_url,
|
||||
failure_url=failure_url,
|
||||
metadata={
|
||||
"payment_flow": "key_renewal",
|
||||
"tariff_id": int(tariff_id),
|
||||
"client_id": str(client_id),
|
||||
"email": key_email,
|
||||
"cost": pricing.final_price_rub,
|
||||
"selected_duration_days": pricing.duration_days,
|
||||
"selected_device_limit": pricing.selected_device_limit,
|
||||
"selected_traffic_limit": pricing.selected_traffic_limit,
|
||||
"selected_price_rub": pricing.final_price_rub,
|
||||
"total_gb": pricing.total_gb,
|
||||
"base_price_rub": pricing.base_price_rub,
|
||||
"discount_rub": pricing.discount_rub,
|
||||
"applied_coupon_code": pricing.applied_coupon_code,
|
||||
"coupon_id": pricing.coupon_id,
|
||||
},
|
||||
)
|
||||
payment_result = await create_payment_link(session, payment_request)
|
||||
if not payment_result.success or not payment_result.payment_url or not payment_result.payment_id:
|
||||
raise HTTPException(status_code=400, detail=payment_result.error or "Не удалось создать ссылку оплаты")
|
||||
await create_temporary_data(
|
||||
session,
|
||||
int(billing_user_id),
|
||||
"waiting_for_renewal_payment",
|
||||
{
|
||||
"tariff_id": int(tariff_id),
|
||||
"client_id": str(client_id),
|
||||
"email": key_email,
|
||||
"cost": pricing.final_price_rub,
|
||||
"required_amount": pricing.required_amount,
|
||||
"selected_duration_days": pricing.duration_days,
|
||||
"selected_device_limit": pricing.selected_device_limit,
|
||||
"selected_traffic_limit": pricing.selected_traffic_limit,
|
||||
"selected_price_rub": pricing.final_price_rub,
|
||||
"total_gb": pricing.total_gb,
|
||||
"base_price_rub": pricing.base_price_rub,
|
||||
"discount_rub": pricing.discount_rub,
|
||||
"applied_coupon_code": pricing.applied_coupon_code,
|
||||
"coupon_id": pricing.coupon_id,
|
||||
},
|
||||
)
|
||||
return AccountKeyRenewResponse(
|
||||
ok=True,
|
||||
message="Требуется оплата для продления подписки",
|
||||
client_id=str(client_id),
|
||||
tariff_id=int(tariff_id),
|
||||
charged_rub=0,
|
||||
balance_rub=pricing.balance,
|
||||
base_price_rub=pricing.base_price_rub,
|
||||
discount_rub=pricing.discount_rub,
|
||||
final_price_rub=pricing.final_price_rub,
|
||||
applied_coupon_code=pricing.applied_coupon_code,
|
||||
payment_required=True,
|
||||
required_amount_rub=pricing.required_amount,
|
||||
payment_id=payment_result.payment_id,
|
||||
payment_url=payment_result.payment_url,
|
||||
)
|
||||
expiry_raw = _normalize_expiry_ms(getattr(db_key, "expiry_time", None))
|
||||
now_ms = int(datetime.utcnow().timestamp() * 1000)
|
||||
base_expiry = now_ms if expiry_raw <= now_ms else expiry_raw
|
||||
new_expiry_time = int(base_expiry + pricing.duration_days * 24 * 60 * 60 * 1000)
|
||||
if not key_email or not key_server_id:
|
||||
raise HTTPException(status_code=400, detail="Некорректные данные подписки")
|
||||
try:
|
||||
result = await execute_renewal(
|
||||
session=session,
|
||||
billing_user_id=int(billing_user_id),
|
||||
client_id=str(client_id),
|
||||
key_email=key_email,
|
||||
key_server_id=key_server_id,
|
||||
tariff_id=int(tariff_id),
|
||||
new_expiry_time=new_expiry_time,
|
||||
total_gb=pricing.total_gb,
|
||||
cost=float(pricing.final_price_rub),
|
||||
selected_device_limit=pricing.selected_device_limit,
|
||||
selected_traffic_limit=pricing.selected_traffic_limit,
|
||||
selected_price_rub=pricing.final_price_rub,
|
||||
coupon_id=pricing.coupon_id,
|
||||
)
|
||||
except ServiceError as e:
|
||||
raise HTTPException(status_code=400, detail=e.message)
|
||||
await session.commit()
|
||||
return AccountKeyRenewResponse(
|
||||
ok=True,
|
||||
message="Подписка продлена",
|
||||
client_id=result.client_id,
|
||||
tariff_id=result.tariff_id,
|
||||
charged_rub=result.charged_rub,
|
||||
balance_rub=result.balance_rub,
|
||||
base_price_rub=pricing.base_price_rub,
|
||||
discount_rub=pricing.discount_rub,
|
||||
final_price_rub=pricing.final_price_rub,
|
||||
applied_coupon_code=pricing.applied_coupon_code,
|
||||
)
|
||||
@@ -3,10 +3,12 @@ import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Literal
|
||||
|
||||
import psutil
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
@@ -23,10 +25,11 @@ from api.v2.schemas.audit import (
|
||||
)
|
||||
from audit import drain_audit_redis_to_db, get_audit_funnel, get_audit_stats, list_audit_events
|
||||
from config import API_TOKEN, BOT_SERVICE
|
||||
from database import async_session_maker
|
||||
from core.bootstrap import MANAGEMENT_CONFIG
|
||||
from core.executor import run_io
|
||||
from core.redis_cache import cache_incr
|
||||
from core.settings.management_config import update_management_config
|
||||
from database import async_session_maker
|
||||
from database.models import Key, ScheduledBroadcast, Server, User
|
||||
from database.scheduled_broadcasts import (
|
||||
cancel_scheduled_broadcast,
|
||||
@@ -48,9 +51,18 @@ from handlers.admin.sender.scheduled_service import (
|
||||
from logger import logger
|
||||
from utils.backup import backup_database
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _admin_rate_limit(request_or_identity, action: str, max_calls: int, window_sec: int) -> None:
|
||||
identity_id = getattr(request_or_identity, "id", "unknown")
|
||||
key = f"admin_rl:{action}:{identity_id}"
|
||||
count = await cache_incr(key, window_sec)
|
||||
if count > max_calls:
|
||||
raise HTTPException(status_code=429, detail="Слишком много запросов. Попробуйте позже.")
|
||||
|
||||
|
||||
class MaintenanceUpdate(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
@@ -174,6 +186,7 @@ async def restart_bot(
|
||||
identity=Depends(verify_identity_admin),
|
||||
):
|
||||
"""Запуск перезапуска бота в фоне."""
|
||||
await _admin_rate_limit(identity, "restart", max_calls=3, window_sec=60)
|
||||
background.add_task(_restart_bot)
|
||||
return {"status": "restarting"}
|
||||
|
||||
@@ -185,6 +198,7 @@ async def change_domain(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Массовая замена домена в ключах и remnawave_link."""
|
||||
await _admin_rate_limit(identity, "change_domain", max_calls=3, window_sec=300)
|
||||
domain = payload.domain.strip()
|
||||
if not domain or " " in domain or not re.fullmatch(r"[a-zA-Z0-9.-]+", domain):
|
||||
raise HTTPException(status_code=400, detail="Invalid domain")
|
||||
@@ -211,11 +225,12 @@ async def restore_trials(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Сбрасывает trial=0 у пользователей без ключей."""
|
||||
await _admin_rate_limit(identity, "restore_trials", max_calls=3, window_sec=300)
|
||||
stmt = (
|
||||
update(User)
|
||||
.where(
|
||||
User.trial == 1,
|
||||
~exists(select(Key.tg_id).where(Key.tg_id == User.tg_id)),
|
||||
~exists(select(Key.user_id).where(Key.user_id == User.id)),
|
||||
)
|
||||
.values(trial=0)
|
||||
)
|
||||
@@ -227,6 +242,7 @@ async def restore_trials(
|
||||
@router.post("/backup")
|
||||
async def trigger_backup(identity=Depends(verify_identity_admin)):
|
||||
"""Запуск бэкапа БД в фоне."""
|
||||
await _admin_rate_limit(identity, "backup", max_calls=2, window_sec=300)
|
||||
|
||||
async def _run_backup() -> None:
|
||||
exception = await backup_database()
|
||||
@@ -357,7 +373,7 @@ async def post_audit_drain(identity=Depends(verify_identity_admin_short)):
|
||||
return {"success": True, "drained": count}
|
||||
except Exception as exc:
|
||||
logger.warning("audit-drain failed: {}", exc)
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
raise HTTPException(status_code=500, detail="Внутренняя ошибка при дренаже аудита") from exc
|
||||
|
||||
|
||||
@router.post("/broadcast")
|
||||
@@ -366,6 +382,7 @@ async def launch_broadcast(
|
||||
identity=Depends(verify_identity_admin_short),
|
||||
):
|
||||
"""Запуск рассылки по выбранной аудитории. Сессия БД не держится на время рассылки."""
|
||||
await _admin_rate_limit(identity, "broadcast", max_calls=5, window_sec=300)
|
||||
try:
|
||||
prepared = prepare_broadcast_payload(
|
||||
send_to=payload.send_to,
|
||||
@@ -475,14 +492,16 @@ async def send_broadcast_schedule_now(
|
||||
identity=Depends(verify_identity_admin_short),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
await _admin_rate_limit(identity, "broadcast_now", max_calls=5, window_sec=300)
|
||||
item = await start_scheduled_broadcast(session, broadcast_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=409, detail="Scheduled broadcast can no longer be sent now")
|
||||
try:
|
||||
result = await execute_scheduled_broadcast(item, bot=_get_broadcast_bot())
|
||||
except Exception as exc:
|
||||
logger.error("[Broadcast] send-now failed for {}: {}", broadcast_id, exc)
|
||||
await mark_scheduled_broadcast_failed(session, broadcast_id, str(exc))
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
raise HTTPException(status_code=500, detail="Ошибка при выполнении рассылки") from exc
|
||||
if result.get("success"):
|
||||
item = await mark_scheduled_broadcast_sent(session, broadcast_id, result)
|
||||
else:
|
||||
|
||||
+17
-5
@@ -13,6 +13,7 @@ from api.v2.schemas import (
|
||||
)
|
||||
from api.v2.base_crud import generate_crud_router
|
||||
from database import get_tracking_source_stats
|
||||
from database.access.resolution import resolve_user_optional
|
||||
from database.models import (
|
||||
BlockedUser,
|
||||
ManualBan,
|
||||
@@ -46,7 +47,10 @@ async def get_payments_by_tg_id(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Список платежей по tg_id пользователя."""
|
||||
result = await session.execute(select(Payment).where(Payment.tg_id == tg_id))
|
||||
u = await resolve_user_optional(session, tg_id)
|
||||
if u is None:
|
||||
raise HTTPException(status_code=404, detail="Payments not found")
|
||||
result = await session.execute(select(Payment).where(Payment.user_id == u.id))
|
||||
payments = result.scalars().all()
|
||||
if not payments:
|
||||
raise HTTPException(status_code=404, detail="Payments not found")
|
||||
@@ -59,7 +63,9 @@ router.include_router(
|
||||
schema_response=NotificationResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="tg_id",
|
||||
identifier_field="user_id",
|
||||
parameter_name="tg_id",
|
||||
telegram_path_to_user_id=True,
|
||||
enabled_methods=["get_all", "get_one", "delete"],
|
||||
),
|
||||
prefix="/notifications",
|
||||
@@ -73,7 +79,9 @@ router.include_router(
|
||||
schema_response=ManualBanResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="tg_id",
|
||||
identifier_field="user_id",
|
||||
parameter_name="tg_id",
|
||||
telegram_path_to_user_id=True,
|
||||
enabled_methods=["get_all", "get_one", "delete"],
|
||||
),
|
||||
prefix="/manual-bans",
|
||||
@@ -87,7 +95,9 @@ router.include_router(
|
||||
schema_response=BlockedUserResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="tg_id",
|
||||
identifier_field="user_id",
|
||||
parameter_name="tg_id",
|
||||
telegram_path_to_user_id=True,
|
||||
enabled_methods=["get_all", "get_one", "delete"],
|
||||
),
|
||||
prefix="/blocked-users",
|
||||
@@ -101,7 +111,9 @@ router.include_router(
|
||||
schema_response=TemporaryDataResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="tg_id",
|
||||
identifier_field="user_id",
|
||||
parameter_name="tg_id",
|
||||
telegram_path_to_user_id=True,
|
||||
enabled_methods=["get_all", "get_one", "delete"],
|
||||
),
|
||||
prefix="/temporary-data",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import pkgutil
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
@@ -7,9 +8,11 @@ from pydantic import BaseModel
|
||||
|
||||
from api.depends import verify_identity_admin
|
||||
from core.executor import run_io
|
||||
from logger import logger
|
||||
from utils.modules_loader import _is_safe_module_name
|
||||
from utils.modules_manager import manager
|
||||
|
||||
|
||||
router = APIRouter(prefix="/modules", tags=["Modules"])
|
||||
|
||||
MODULES_DIR = Path(__file__).resolve().parents[3] / "modules"
|
||||
@@ -117,5 +120,6 @@ async def control_module(module_name: str, payload: ModuleAction, identity=Depen
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
logger.error("[Modules] action failed for {}: {}", name, exc)
|
||||
raise HTTPException(status_code=500, detail="Ошибка при выполнении операции модуля") from exc
|
||||
return {"item": _module_state(name)}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_identity_token
|
||||
from database.models import Identity
|
||||
from database import web_notifications as wn_db
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class PushSubscribeRequest(BaseModel):
|
||||
endpoint: str
|
||||
keys: dict
|
||||
|
||||
|
||||
class NotificationItem(BaseModel):
|
||||
id: str
|
||||
type: str
|
||||
title: str
|
||||
message: str
|
||||
read: bool
|
||||
created_at: str
|
||||
data: dict | None = None
|
||||
|
||||
|
||||
class NotificationsResponse(BaseModel):
|
||||
ok: bool = True
|
||||
notifications: list[NotificationItem]
|
||||
unread_count: int
|
||||
|
||||
|
||||
@router.post("/push/subscribe", tags=["Notifications"])
|
||||
async def push_subscribe(
|
||||
body: PushSubscribeRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity: Identity = Depends(verify_identity_token),
|
||||
):
|
||||
user_id = identity.tg_id or 0
|
||||
|
||||
await wn_db.upsert_push_subscription(
|
||||
session,
|
||||
user_id=user_id,
|
||||
identity_id=identity.id,
|
||||
endpoint=body.endpoint,
|
||||
keys_json=body.keys,
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/notifications", response_model=NotificationsResponse, tags=["Notifications"])
|
||||
async def get_notifications(
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity: Identity = Depends(verify_identity_token),
|
||||
):
|
||||
notifications = await wn_db.get_notifications_for_identity(
|
||||
session, identity.id, limit=limit,
|
||||
)
|
||||
unread_count = await wn_db.count_unread_for_identity(session, identity.id)
|
||||
|
||||
items = [
|
||||
NotificationItem(
|
||||
id=n.id,
|
||||
type=n.type,
|
||||
title=n.title,
|
||||
message=n.message,
|
||||
read=n.read,
|
||||
created_at=n.created_at.isoformat() if n.created_at else "",
|
||||
data=n.data,
|
||||
)
|
||||
for n in notifications
|
||||
]
|
||||
return NotificationsResponse(notifications=items, unread_count=unread_count)
|
||||
|
||||
|
||||
@router.post("/notifications/read-all", tags=["Notifications"])
|
||||
async def read_all_notifications(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity: Identity = Depends(verify_identity_token),
|
||||
):
|
||||
count = await wn_db.mark_all_read_for_identity(session, identity.id)
|
||||
return {"ok": True, "updated": count}
|
||||
+465
-3
@@ -1,14 +1,31 @@
|
||||
import csv
|
||||
from base64 import b64encode
|
||||
from datetime import datetime
|
||||
from io import StringIO
|
||||
from io import BytesIO, StringIO
|
||||
import re
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
import qrcode
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_identity_admin
|
||||
from api.depends import get_request_actor, get_session, verify_identity_admin, verify_identity_token
|
||||
from api.v2.schemas.web_public import (
|
||||
PartnerApplyRequest,
|
||||
PartnerApplyResponse,
|
||||
PartnerConditionsResponse,
|
||||
PartnerQrResponse,
|
||||
PartnerTopEntryResponse,
|
||||
PartnerTopResponse,
|
||||
PartnerPayoutEntryResponse,
|
||||
PartnerPayoutHistoryResponse,
|
||||
PartnerPayoutRequestCreate,
|
||||
PartnerPayoutRequestResponse,
|
||||
)
|
||||
from database import identities as idb
|
||||
from utils.referral_codes import decode_partner_code, encode_partner_code
|
||||
|
||||
try:
|
||||
from modules.partner_program.settings import PARTNER_BONUS_PERCENTAGES
|
||||
@@ -44,6 +61,451 @@ def _row_dt_iso(value) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_public_base_url(request: Request) -> str:
|
||||
origin = str(request.headers.get("origin") or "").strip()
|
||||
if origin.startswith("http://") or origin.startswith("https://"):
|
||||
return origin.rstrip("/")
|
||||
referer = str(request.headers.get("referer") or request.headers.get("referrer") or "").strip()
|
||||
if referer.startswith("http://") or referer.startswith("https://"):
|
||||
parsed = urlsplit(referer)
|
||||
if parsed.scheme and parsed.netloc:
|
||||
return f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
|
||||
forwarded_host = str(request.headers.get("x-forwarded-host") or "").strip()
|
||||
host = forwarded_host or str(request.headers.get("host") or "").strip()
|
||||
forwarded_proto = str(request.headers.get("x-forwarded-proto") or "").split(",", 1)[0].strip().lower()
|
||||
scheme = forwarded_proto if forwarded_proto in {"http", "https"} else request.url.scheme
|
||||
if host:
|
||||
return f"{scheme}://{host}".rstrip("/")
|
||||
return str(request.base_url).rstrip("/")
|
||||
|
||||
|
||||
async def _ensure_partner_code(session: AsyncSession, user_id: int, raw_code: str | None) -> str:
|
||||
code = str(raw_code or "").strip()
|
||||
if code and not code.isdigit() and not code.startswith("r1_"):
|
||||
return code
|
||||
generated = encode_partner_code(int(user_id))
|
||||
try:
|
||||
await session.execute(
|
||||
text("UPDATE users SET partner_code = :code WHERE id = :id"),
|
||||
{"code": generated, "id": int(user_id)},
|
||||
)
|
||||
await session.flush()
|
||||
except Exception:
|
||||
pass
|
||||
return generated
|
||||
|
||||
|
||||
async def _resolve_partner_user(session: AsyncSession, request: Request, identity) -> tuple[int, int]:
|
||||
actor = get_request_actor(request)
|
||||
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
|
||||
if billing_user_id is None:
|
||||
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
row = (
|
||||
await session.execute(
|
||||
text("SELECT id, tg_id FROM users WHERE id = :user_id LIMIT 1"),
|
||||
{"user_id": int(billing_user_id)},
|
||||
)
|
||||
).first()
|
||||
if row is None or row[1] is None:
|
||||
raise HTTPException(status_code=400, detail="Партнерский профиль недоступен")
|
||||
return int(row[0]), int(row[1])
|
||||
|
||||
|
||||
async def _resolve_referrer_by_partner_code(session: AsyncSession, partner_code: str) -> tuple[int, int] | None:
|
||||
code = str(partner_code or "").strip()
|
||||
if not code:
|
||||
return None
|
||||
by_code_row = (
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id, tg_id
|
||||
FROM users
|
||||
WHERE lower(COALESCE(partner_code, '')) = lower(:code)
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"code": code},
|
||||
)
|
||||
).first()
|
||||
if by_code_row is not None:
|
||||
user_id = int(by_code_row[0])
|
||||
tg_id = int(by_code_row[1] if by_code_row[1] is not None else by_code_row[0])
|
||||
return user_id, tg_id
|
||||
decoded = decode_partner_code(code)
|
||||
if decoded is None:
|
||||
return None
|
||||
by_id_row = (
|
||||
await session.execute(
|
||||
text("SELECT id, tg_id FROM users WHERE id = :id LIMIT 1"),
|
||||
{"id": int(decoded)},
|
||||
)
|
||||
).first()
|
||||
if by_id_row is not None:
|
||||
user_id = int(by_id_row[0])
|
||||
tg_id = int(by_id_row[1] if by_id_row[1] is not None else by_id_row[0])
|
||||
return user_id, tg_id
|
||||
by_tg_row = (
|
||||
await session.execute(
|
||||
text("SELECT id, tg_id FROM users WHERE tg_id = :tg_id LIMIT 1"),
|
||||
{"tg_id": int(decoded)},
|
||||
)
|
||||
).first()
|
||||
if by_tg_row is not None:
|
||||
user_id = int(by_tg_row[0])
|
||||
tg_id = int(by_tg_row[1] if by_tg_row[1] is not None else by_tg_row[0])
|
||||
return user_id, tg_id
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/apply", response_model=PartnerApplyResponse)
|
||||
async def partner_apply(
|
||||
body: PartnerApplyRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
joined_user_id, joined_tg_id = await _resolve_partner_user(session, request, identity)
|
||||
code_value = str(body.partner_code or "").strip()
|
||||
referrer_user_id: int | None = None
|
||||
referrer_tg_id: int | None = None
|
||||
if code_value:
|
||||
resolved = await _resolve_referrer_by_partner_code(session, code_value)
|
||||
if resolved is not None:
|
||||
referrer_user_id, referrer_tg_id = resolved
|
||||
if referrer_tg_id is None and body.partner_tg_id is not None:
|
||||
referrer_tg_id = int(body.partner_tg_id)
|
||||
referrer_user_id_row = (
|
||||
await session.execute(
|
||||
text("SELECT id FROM users WHERE tg_id = :tg_id LIMIT 1"),
|
||||
{"tg_id": int(referrer_tg_id)},
|
||||
)
|
||||
).first()
|
||||
if referrer_user_id_row is not None:
|
||||
referrer_user_id = int(referrer_user_id_row[0])
|
||||
if referrer_tg_id is None:
|
||||
raise HTTPException(status_code=400, detail="Партнерский код не найден")
|
||||
if int(referrer_tg_id) == int(joined_tg_id):
|
||||
raise HTTPException(status_code=400, detail="Нельзя применить свой партнерский код")
|
||||
already_row = (
|
||||
await session.execute(
|
||||
text("SELECT partner_tg_id FROM partners WHERE joined_tg_id = :joined_tg_id LIMIT 1"),
|
||||
{"joined_tg_id": int(joined_tg_id)},
|
||||
)
|
||||
).first()
|
||||
if already_row is not None and already_row[0] is not None:
|
||||
raise HTTPException(status_code=409, detail="Партнер уже привязан")
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO partners (partner_tg_id, joined_tg_id)
|
||||
VALUES (:partner_tg_id, :joined_tg_id)
|
||||
"""
|
||||
),
|
||||
{"partner_tg_id": int(referrer_tg_id), "joined_tg_id": int(joined_tg_id)},
|
||||
)
|
||||
await session.commit()
|
||||
return PartnerApplyResponse(
|
||||
ok=True,
|
||||
message="Партнерский код применен",
|
||||
partner_code=code_value,
|
||||
partner_user_id=int(referrer_user_id or 0),
|
||||
partner_tg_id=int(referrer_tg_id),
|
||||
joined_user_id=int(joined_user_id),
|
||||
joined_tg_id=int(joined_tg_id),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/qr", response_model=PartnerQrResponse)
|
||||
async def partner_qr(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
user_id, _ = await _resolve_partner_user(session, request, identity)
|
||||
code_row = (
|
||||
await session.execute(
|
||||
text("SELECT partner_code FROM users WHERE id = :id LIMIT 1"),
|
||||
{"id": int(user_id)},
|
||||
)
|
||||
).first()
|
||||
partner_code = await _ensure_partner_code(session, int(user_id), code_row[0] if code_row else None)
|
||||
base_url = _resolve_public_base_url(request)
|
||||
partner_link = f"{base_url}/partner/{partner_code}"
|
||||
qr = qrcode.QRCode(version=1, box_size=10, border=4)
|
||||
qr.add_data(partner_link)
|
||||
qr.make(fit=True)
|
||||
image = qr.make_image(fill_color="black", back_color="white")
|
||||
png_buffer = BytesIO()
|
||||
image.save(png_buffer, format="PNG")
|
||||
image_data = b64encode(png_buffer.getvalue()).decode("ascii")
|
||||
return PartnerQrResponse(
|
||||
ok=True,
|
||||
link=partner_link,
|
||||
image_data_url=f"data:image/png;base64,{image_data}",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/top", response_model=PartnerTopResponse)
|
||||
async def partner_top(
|
||||
request: Request,
|
||||
limit: int = Query(5, ge=1, le=20),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
_, joined_tg_id = await _resolve_partner_user(session, request, identity)
|
||||
user_referred_count_row = (
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT COUNT(DISTINCT joined_tg_id)
|
||||
FROM partners
|
||||
WHERE partner_tg_id = :partner_tg_id
|
||||
"""
|
||||
),
|
||||
{"partner_tg_id": int(joined_tg_id)},
|
||||
)
|
||||
).first()
|
||||
user_referred_count = int(user_referred_count_row[0] or 0) if user_referred_count_row else 0
|
||||
user_position: int | None = None
|
||||
if user_referred_count > 0:
|
||||
user_position_row = (
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT COUNT(*) + 1
|
||||
FROM (
|
||||
SELECT partner_tg_id, COUNT(DISTINCT joined_tg_id) AS referred_count
|
||||
FROM partners
|
||||
WHERE partner_tg_id IS NOT NULL
|
||||
GROUP BY partner_tg_id
|
||||
) ranked
|
||||
WHERE ranked.referred_count > :referred_count
|
||||
"""
|
||||
),
|
||||
{"referred_count": int(user_referred_count)},
|
||||
)
|
||||
).first()
|
||||
user_position = int(user_position_row[0] or 1) if user_position_row else 1
|
||||
top_rows = (
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT
|
||||
COALESCE(u.id, 0) AS partner_user_id,
|
||||
p.partner_tg_id AS partner_tg_id,
|
||||
COUNT(DISTINCT p.joined_tg_id) AS referred_count
|
||||
FROM partners p
|
||||
LEFT JOIN users u ON u.tg_id = p.partner_tg_id
|
||||
WHERE p.partner_tg_id IS NOT NULL
|
||||
GROUP BY p.partner_tg_id, u.id
|
||||
ORDER BY referred_count DESC, p.partner_tg_id ASC
|
||||
LIMIT :limit
|
||||
"""
|
||||
),
|
||||
{"limit": int(limit)},
|
||||
)
|
||||
).all()
|
||||
top: list[PartnerTopEntryResponse] = []
|
||||
for index, row in enumerate(top_rows, 1):
|
||||
partner_user_id = int(row[0] or 0)
|
||||
partner_tg_id = int(row[1] or 0)
|
||||
referred_count = int(row[2] or 0)
|
||||
if partner_user_id > 0:
|
||||
display_id = encode_partner_code(partner_user_id)
|
||||
else:
|
||||
tg_tail = str(partner_tg_id)
|
||||
display_id = f"p_{tg_tail[:2]}***{tg_tail[-2:]}" if tg_tail else "p_***"
|
||||
top.append(
|
||||
PartnerTopEntryResponse(
|
||||
position=index,
|
||||
partner_user_id=partner_user_id,
|
||||
referred_count=referred_count,
|
||||
display_id=display_id,
|
||||
)
|
||||
)
|
||||
return PartnerTopResponse(
|
||||
user_referred_count=user_referred_count,
|
||||
user_position=user_position,
|
||||
top=top,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/conditions", response_model=PartnerConditionsResponse)
|
||||
async def partner_conditions(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
try:
|
||||
from modules.partner_program import settings as partner_settings
|
||||
except Exception:
|
||||
partner_settings = None
|
||||
mode = str(getattr(partner_settings, "REFERRAL_REWARD_MODE", "percent_only") or "percent_only")
|
||||
percent_levels_raw = getattr(partner_settings, "PARTNER_BONUS_PERCENTAGES", {}) or {}
|
||||
flat_levels_raw = getattr(partner_settings, "PARTNER_FLAT_BONUSES", {}) or {}
|
||||
min_payout = float(getattr(partner_settings, "MIN_PARTNER_PAYOUT", 0) or 0)
|
||||
custom_amount_enabled = bool(getattr(partner_settings, "ENABLE_CUSTOM_WITHDRAW_AMOUNT", False))
|
||||
method_map = [
|
||||
("ENABLE_PAYOUT_CARD", "Карта"),
|
||||
("ENABLE_PAYOUT_SBP", "СБП"),
|
||||
("ENABLE_PAYOUT_USDT", "USDT"),
|
||||
("ENABLE_PAYOUT_TON", "TON"),
|
||||
]
|
||||
payout_methods = [
|
||||
title for key, title in method_map if bool(getattr(partner_settings, key, False))
|
||||
] if partner_settings else []
|
||||
level_lines: list[str] = []
|
||||
all_levels = sorted({int(k) for k in [*percent_levels_raw.keys(), *flat_levels_raw.keys()] if str(k).isdigit()})
|
||||
for level in all_levels:
|
||||
parts: list[str] = []
|
||||
if level in percent_levels_raw:
|
||||
try:
|
||||
parts.append(f"{float(percent_levels_raw[level]) * 100:.0f}%")
|
||||
except Exception:
|
||||
pass
|
||||
if level in flat_levels_raw:
|
||||
try:
|
||||
parts.append(f"{float(flat_levels_raw[level]):.0f} RUB")
|
||||
except Exception:
|
||||
pass
|
||||
if parts:
|
||||
level_lines.append(f"{level} уровень: {' + '.join(parts)}")
|
||||
if not level_lines:
|
||||
level_lines = ["1 уровень: бонус определяется настройками проекта"]
|
||||
mode_labels = {
|
||||
"percent_only": "Процент с каждого пополнения приглашенного",
|
||||
"flat_only": "Фиксированный бонус за первую оплату приглашенного",
|
||||
"flat_plus_percent": "Фиксированный бонус за первую оплату и процент с пополнений",
|
||||
}
|
||||
rules = [
|
||||
"Вознаграждение начисляется только после успешной оплаты приглашенного пользователя.",
|
||||
"Самореферал и самопартнерство недоступны.",
|
||||
f"Минимальная сумма вывода: {min_payout:.0f} RUB." if min_payout > 0 else "Вывод доступен по правилам проекта.",
|
||||
]
|
||||
if payout_methods:
|
||||
rules.append(f"Доступные способы вывода: {', '.join(payout_methods)}.")
|
||||
examples = [
|
||||
"Пример: приглашенный пополнил на 1000 RUB, а ставка 15% — вы получаете 150 RUB.",
|
||||
"Пример: приглашенный сделал несколько пополнений, бонус считается по каждой успешной операции.",
|
||||
]
|
||||
return PartnerConditionsResponse(
|
||||
title="Условия партнерской программы",
|
||||
summary="Актуальные условия и режим начислений для партнеров.",
|
||||
bonus_mode=mode,
|
||||
bonus_mode_label=mode_labels.get(mode, mode_labels["percent_only"]),
|
||||
level_lines=level_lines,
|
||||
rules=rules,
|
||||
examples=examples,
|
||||
min_payout_rub=min_payout,
|
||||
payout_methods=payout_methods,
|
||||
custom_amount_enabled=custom_amount_enabled,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/payouts/me", response_model=PartnerPayoutHistoryResponse)
|
||||
async def partner_payouts_me(
|
||||
request: Request,
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
_, tg_id = await _resolve_partner_user(session, request, identity)
|
||||
count_sql = text("SELECT COUNT(*) FROM payout_requests WHERE tg_id = :tg_id")
|
||||
rows_sql = text(
|
||||
"""
|
||||
SELECT id, amount, status, created_at, method, destination
|
||||
FROM payout_requests
|
||||
WHERE tg_id = :tg_id
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""
|
||||
)
|
||||
total = int((await session.scalar(count_sql, {"tg_id": tg_id})) or 0)
|
||||
rows = (
|
||||
await session.execute(rows_sql, {"tg_id": tg_id, "limit": int(limit), "offset": int(offset)})
|
||||
).fetchall()
|
||||
items = [
|
||||
PartnerPayoutEntryResponse(
|
||||
id=int(row[0]),
|
||||
amount_rub=float(row[1] or 0.0),
|
||||
status=str(row[2] or ""),
|
||||
created_at=_row_dt_iso(row[3]),
|
||||
method=row[4] or None,
|
||||
destination=row[5] or None,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
return PartnerPayoutHistoryResponse(total=total, items=items)
|
||||
|
||||
|
||||
@router.post("/payouts/me", response_model=PartnerPayoutRequestResponse)
|
||||
async def partner_create_payout_request(
|
||||
body: PartnerPayoutRequestCreate,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
user_id, tg_id = await _resolve_partner_user(session, request, identity)
|
||||
row = (
|
||||
await session.execute(
|
||||
text("SELECT COALESCE(partner_balance, 0), payout_method, card_number FROM users WHERE id = :id"),
|
||||
{"id": user_id},
|
||||
)
|
||||
).first()
|
||||
balance = float(row[0] or 0.0) if row else 0.0
|
||||
requested = float(body.amount_rub)
|
||||
if requested <= 0:
|
||||
raise HTTPException(status_code=400, detail="Сумма должна быть больше нуля")
|
||||
try:
|
||||
from modules.partner_program.settings import ENABLE_CUSTOM_WITHDRAW_AMOUNT, MIN_PARTNER_PAYOUT
|
||||
except Exception:
|
||||
ENABLE_CUSTOM_WITHDRAW_AMOUNT = True
|
||||
MIN_PARTNER_PAYOUT = 0
|
||||
min_payout = float(MIN_PARTNER_PAYOUT or 0)
|
||||
if requested < min_payout:
|
||||
raise HTTPException(status_code=400, detail=f"Минимальная сумма вывода — {min_payout:.0f} RUB")
|
||||
if not bool(ENABLE_CUSTOM_WITHDRAW_AMOUNT):
|
||||
requested = balance
|
||||
if requested > balance:
|
||||
raise HTTPException(status_code=400, detail="Недостаточно партнерского баланса")
|
||||
if requested <= 0:
|
||||
raise HTTPException(status_code=400, detail="Недостаточно средств для заявки")
|
||||
payout_method = (row[1] if row else None) or "card"
|
||||
destination = (row[2] if row else None) or None
|
||||
inserted = (
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO payout_requests (tg_id, amount, status, created_at, method, destination)
|
||||
VALUES (:tg_id, :amount, 'pending', NOW(), :method, :destination)
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"tg_id": int(tg_id),
|
||||
"amount": float(requested),
|
||||
"method": payout_method,
|
||||
"destination": destination,
|
||||
},
|
||||
)
|
||||
).scalar()
|
||||
new_balance = balance - requested
|
||||
await session.execute(
|
||||
text("UPDATE users SET partner_balance = :balance WHERE id = :id"),
|
||||
{"balance": new_balance, "id": int(user_id)},
|
||||
)
|
||||
await session.commit()
|
||||
return PartnerPayoutRequestResponse(
|
||||
ok=True,
|
||||
message="Заявка на вывод создана",
|
||||
request_id=int(inserted) if inserted is not None else None,
|
||||
amount_rub=float(requested),
|
||||
status="pending",
|
||||
balance_rub=float(new_balance),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/all")
|
||||
async def get_all_partners(
|
||||
limit: int = Query(1000, ge=1, le=10000, description="Лимит результатов"),
|
||||
|
||||
+240
-23
@@ -1,28 +1,143 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_identity_token
|
||||
from api.v2.schemas.payment_links import PaymentLinkCreateRequest, PaymentLinkCreateResponse
|
||||
from database import identities as idb
|
||||
from handlers.payments import create_payment_link
|
||||
from handlers.payments.payment_links import PaymentLinkRequest
|
||||
from api.v2.schemas.payment_links import PaymentLinkCreateRequest, PaymentLinkCreateResponse, PaymentLinkStatusResponse
|
||||
from config import REDIS_URL
|
||||
from database import (
|
||||
async_session_maker,
|
||||
get_payment_by_payment_id,
|
||||
get_payment_from_db_by_payment_id,
|
||||
identities as idb,
|
||||
)
|
||||
from database.temporary_data import create_temporary_data
|
||||
from logger import logger
|
||||
from services.payments.payment_events import payment_events_channel
|
||||
from services.payments.payment_links import PaymentLinkRequest, create_payment_link
|
||||
|
||||
|
||||
router = APIRouter(tags=["PaymentLinks"])
|
||||
|
||||
|
||||
async def _resolve_tg_id(body: PaymentLinkCreateRequest, session: AsyncSession) -> int:
|
||||
"""Возвращает tg_id из body.tg_id или из identity_id; иначе исключение."""
|
||||
if body.tg_id is not None:
|
||||
return body.tg_id
|
||||
if body.identity_id:
|
||||
tg_id = await idb.resolve_tg_id(session, body.identity_id)
|
||||
if tg_id is not None:
|
||||
return tg_id
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="У идентичности не привязан Telegram. Привяжите tg_id для создания платёжной ссылки.",
|
||||
)
|
||||
raise HTTPException(status_code=400, detail="Укажите tg_id или identity_id")
|
||||
async def _store_payment_intent(
|
||||
session: AsyncSession,
|
||||
billing_user_ref: int,
|
||||
metadata: dict | None,
|
||||
amount: int | float,
|
||||
) -> None:
|
||||
if not isinstance(metadata, dict):
|
||||
return
|
||||
payment_flow = str(metadata.get("payment_flow") or "").strip().lower()
|
||||
required_amount = int(round(float(amount)))
|
||||
if payment_flow == "tariff_purchase":
|
||||
tariff_id = metadata.get("tariff_id")
|
||||
if tariff_id in (None, ""):
|
||||
return
|
||||
payload: dict[str, int | str] = {
|
||||
"tariff_id": int(tariff_id),
|
||||
"required_amount": required_amount,
|
||||
"selected_price_rub": int(metadata.get("selected_price_rub") or required_amount),
|
||||
}
|
||||
selected_device_limit = metadata.get("selected_device_limit")
|
||||
if selected_device_limit not in (None, ""):
|
||||
payload["selected_device_limit"] = int(selected_device_limit)
|
||||
selected_traffic_gb = metadata.get("selected_traffic_gb")
|
||||
if selected_traffic_gb not in (None, ""):
|
||||
payload["selected_traffic_limit_gb"] = int(selected_traffic_gb)
|
||||
selected_duration_days = metadata.get("selected_duration_days")
|
||||
if selected_duration_days not in (None, ""):
|
||||
payload["selected_duration_days"] = int(selected_duration_days)
|
||||
coupon_id = metadata.get("coupon_id")
|
||||
if coupon_id not in (None, ""):
|
||||
payload["coupon_id"] = int(coupon_id)
|
||||
discount_rub = metadata.get("discount_rub")
|
||||
if discount_rub not in (None, ""):
|
||||
payload["discount_rub"] = int(discount_rub)
|
||||
base_price_rub = metadata.get("base_price_rub")
|
||||
if base_price_rub not in (None, ""):
|
||||
payload["base_price_rub"] = int(base_price_rub)
|
||||
applied_coupon_code = metadata.get("applied_coupon_code")
|
||||
if applied_coupon_code not in (None, ""):
|
||||
payload["applied_coupon_code"] = str(applied_coupon_code)
|
||||
await create_temporary_data(session, billing_user_ref, "waiting_for_payment", payload)
|
||||
return
|
||||
if payment_flow == "key_renewal":
|
||||
required_fields = ("tariff_id", "client_id", "email", "cost")
|
||||
if any(metadata.get(field) in (None, "") for field in required_fields):
|
||||
return
|
||||
payload: dict[str, int | str] = {
|
||||
"tariff_id": int(metadata["tariff_id"]),
|
||||
"client_id": str(metadata["client_id"]),
|
||||
"email": str(metadata["email"]),
|
||||
"cost": int(metadata["cost"]),
|
||||
"required_amount": required_amount,
|
||||
"selected_price_rub": int(metadata.get("selected_price_rub") or metadata["cost"]),
|
||||
}
|
||||
selected_duration_days = metadata.get("selected_duration_days")
|
||||
if selected_duration_days not in (None, ""):
|
||||
payload["selected_duration_days"] = int(selected_duration_days)
|
||||
selected_device_limit = metadata.get("selected_device_limit")
|
||||
if selected_device_limit not in (None, ""):
|
||||
payload["selected_device_limit"] = int(selected_device_limit)
|
||||
selected_traffic_limit = metadata.get("selected_traffic_limit")
|
||||
if selected_traffic_limit not in (None, ""):
|
||||
payload["selected_traffic_limit"] = int(selected_traffic_limit)
|
||||
total_gb = metadata.get("total_gb")
|
||||
if total_gb not in (None, ""):
|
||||
payload["total_gb"] = int(total_gb)
|
||||
coupon_id = metadata.get("coupon_id")
|
||||
if coupon_id not in (None, ""):
|
||||
payload["coupon_id"] = int(coupon_id)
|
||||
discount_rub = metadata.get("discount_rub")
|
||||
if discount_rub not in (None, ""):
|
||||
payload["discount_rub"] = int(discount_rub)
|
||||
base_price_rub = metadata.get("base_price_rub")
|
||||
if base_price_rub not in (None, ""):
|
||||
payload["base_price_rub"] = int(base_price_rub)
|
||||
applied_coupon_code = metadata.get("applied_coupon_code")
|
||||
if applied_coupon_code not in (None, ""):
|
||||
payload["applied_coupon_code"] = str(applied_coupon_code)
|
||||
await create_temporary_data(session, billing_user_ref, "waiting_for_renewal_payment", payload)
|
||||
return
|
||||
if payment_flow == "key_addons":
|
||||
required_fields = ("tariff_id", "email", "original_price")
|
||||
if any(metadata.get(field) in (None, "") for field in required_fields):
|
||||
return
|
||||
payload: dict[str, int | str] = {
|
||||
"tariff_id": int(metadata["tariff_id"]),
|
||||
"email": str(metadata["email"]),
|
||||
"original_price": int(metadata["original_price"]),
|
||||
"required_amount": required_amount,
|
||||
}
|
||||
selected_device_limit = metadata.get("selected_device_limit")
|
||||
if selected_device_limit not in (None, ""):
|
||||
payload["selected_device_limit"] = int(selected_device_limit)
|
||||
selected_traffic_gb = metadata.get("selected_traffic_gb")
|
||||
if selected_traffic_gb not in (None, ""):
|
||||
payload["selected_traffic_gb"] = int(selected_traffic_gb)
|
||||
current_device_limit = metadata.get("current_device_limit")
|
||||
if current_device_limit not in (None, ""):
|
||||
payload["current_device_limit"] = int(current_device_limit)
|
||||
current_traffic_gb = metadata.get("current_traffic_gb")
|
||||
if current_traffic_gb not in (None, ""):
|
||||
payload["current_traffic_gb"] = int(current_traffic_gb)
|
||||
coupon_id = metadata.get("coupon_id")
|
||||
if coupon_id not in (None, ""):
|
||||
payload["coupon_id"] = int(coupon_id)
|
||||
discount_rub = metadata.get("discount_rub")
|
||||
if discount_rub not in (None, ""):
|
||||
payload["discount_rub"] = int(discount_rub)
|
||||
base_price_rub = metadata.get("base_price_rub")
|
||||
if base_price_rub not in (None, ""):
|
||||
payload["base_price_rub"] = int(base_price_rub)
|
||||
applied_coupon_code = metadata.get("applied_coupon_code")
|
||||
if applied_coupon_code not in (None, ""):
|
||||
payload["applied_coupon_code"] = str(applied_coupon_code)
|
||||
await create_temporary_data(session, billing_user_ref, "waiting_for_addons_payment", payload)
|
||||
|
||||
|
||||
@router.post("/", response_model=PaymentLinkCreateResponse)
|
||||
@@ -32,13 +147,10 @@ async def create_link(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
"""Создаёт платёжную ссылку через выбранную кассу (единая точка входа). Принимает identity_id или tg_id."""
|
||||
try:
|
||||
tg_id = await _resolve_tg_id(body, session)
|
||||
except HTTPException:
|
||||
raise
|
||||
"""Создаёт платёжную ссылку для текущего авторизованного пользователя."""
|
||||
billing_user_ref = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
payment_request = PaymentLinkRequest(
|
||||
tg_id=tg_id,
|
||||
legacy_user_ref=billing_user_ref,
|
||||
amount=body.amount,
|
||||
currency=body.currency or "RUB",
|
||||
provider_id=body.provider_id,
|
||||
@@ -47,9 +159,114 @@ async def create_link(
|
||||
metadata=body.metadata,
|
||||
)
|
||||
result = await create_payment_link(session, payment_request)
|
||||
if result.success:
|
||||
await _store_payment_intent(
|
||||
session=session,
|
||||
billing_user_ref=billing_user_ref,
|
||||
metadata=body.metadata,
|
||||
amount=body.amount,
|
||||
)
|
||||
return PaymentLinkCreateResponse(
|
||||
success=result.success,
|
||||
payment_id=result.payment_id,
|
||||
payment_url=result.payment_url,
|
||||
error=result.error,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stream")
|
||||
async def payment_events_stream(
|
||||
request: Request,
|
||||
x_identity_id: str = "",
|
||||
token: str = "",
|
||||
):
|
||||
identity_id = str(request.headers.get("X-Identity-Id") or x_identity_id or "").strip()
|
||||
token = str(request.headers.get("X-Token") or token or "").strip()
|
||||
if not identity_id or not token:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
async with async_session_maker() as session:
|
||||
identity = await idb.verify_identity_token(session, identity_id, token)
|
||||
if not identity:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
billing_user_ref = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
await session.commit()
|
||||
|
||||
async def event_generator():
|
||||
redis_client = None
|
||||
pubsub = None
|
||||
channel = payment_events_channel(int(billing_user_ref))
|
||||
try:
|
||||
from redis.asyncio import from_url
|
||||
|
||||
redis_client = from_url(REDIS_URL, encoding="utf-8", decode_responses=True, max_connections=8)
|
||||
pubsub = redis_client.pubsub(ignore_subscribe_messages=True)
|
||||
await pubsub.subscribe(channel)
|
||||
logger.info(f"[Payments] SSE subscribed: user_ref={billing_user_ref}, channel={channel}")
|
||||
yield "retry: 1500\n\n"
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
logger.info(f"[Payments] SSE disconnected by client: user_ref={billing_user_ref}")
|
||||
break
|
||||
message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=15.0)
|
||||
if message and message.get("type") == "message":
|
||||
raw_data = message.get("data")
|
||||
payload = json.loads(raw_data) if isinstance(raw_data, str) else raw_data
|
||||
if isinstance(payload, dict):
|
||||
logger.info(
|
||||
f"[Payments] SSE emit: user_ref={billing_user_ref}, "
|
||||
f"status={payload.get('status')}, flow={payload.get('flow')}"
|
||||
)
|
||||
yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
||||
continue
|
||||
yield ": keepalive\n\n"
|
||||
await asyncio.sleep(0.1)
|
||||
finally:
|
||||
if pubsub is not None:
|
||||
try:
|
||||
await pubsub.unsubscribe(channel)
|
||||
await pubsub.close()
|
||||
except Exception:
|
||||
pass
|
||||
if redis_client is not None:
|
||||
try:
|
||||
await redis_client.aclose()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return StreamingResponse(
|
||||
event_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{payment_id}", response_model=PaymentLinkStatusResponse)
|
||||
async def get_link_status(
|
||||
payment_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
billing_user_ref = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
payment = await get_payment_from_db_by_payment_id(session, payment_id)
|
||||
if payment is None:
|
||||
payment = await get_payment_by_payment_id(session, payment_id)
|
||||
if not payment:
|
||||
raise HTTPException(status_code=404, detail="Payment not found")
|
||||
owner_ref = payment.get("user_id")
|
||||
if owner_ref is None:
|
||||
owner_ref = payment.get("tg_id")
|
||||
if owner_ref is None or int(owner_ref) != int(billing_user_ref):
|
||||
raise HTTPException(status_code=404, detail="Payment not found")
|
||||
status = str(payment.get("status") or "").lower() or None
|
||||
return PaymentLinkStatusResponse(
|
||||
success=True,
|
||||
payment_id=payment_id,
|
||||
status=status,
|
||||
completed=status in {"success", "failed", "cancelled"},
|
||||
paid=status == "success",
|
||||
)
|
||||
|
||||
+179
-29
@@ -1,37 +1,187 @@
|
||||
from fastapi import Depends, HTTPException, Query
|
||||
from sqlalchemy import select
|
||||
from base64 import b64encode
|
||||
from io import BytesIO
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import qrcode
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_identity_admin
|
||||
from api.v2.base_crud import generate_crud_router
|
||||
from api.v2.schemas import ReferralResponse
|
||||
from database.models import Referral
|
||||
|
||||
router = generate_crud_router(
|
||||
model=Referral,
|
||||
schema_response=ReferralResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="referrer_tg_id",
|
||||
parameter_name="referrer_tg_id",
|
||||
enabled_methods=["get_all", "get_one", "get_all_by_field"],
|
||||
from api.depends import get_session, verify_identity_token
|
||||
from api.v2.schemas.web_public import (
|
||||
ReferralApplyRequest,
|
||||
ReferralApplyResponse,
|
||||
ReferralConditionsResponse,
|
||||
ReferralQrResponse,
|
||||
ReferralTopEntryResponse,
|
||||
ReferralTopResponse,
|
||||
)
|
||||
from config import CHECK_REFERRAL_REWARD_ISSUED, REFERRAL_BONUS_PERCENTAGES, REFERRAL_BUTTON, REFERRAL_QR, TOP_REFERRAL_BUTTON
|
||||
from core.bootstrap import BUTTONS_CONFIG
|
||||
from database import add_referral, get_referral_by_referred_id, get_user_referral_count
|
||||
from database.referrals import get_referral_position, get_top_referrals
|
||||
from database import identities as idb
|
||||
from database.access.resolution import resolve_user_optional
|
||||
from utils.referral_codes import decode_referral_code, encode_referral_code
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.delete("/one")
|
||||
async def delete_one_referral(
|
||||
referrer_tg_id: int = Query(..., description="ID пригласившего"),
|
||||
referred_tg_id: int = Query(..., description="ID приглашённого"),
|
||||
identity=Depends(verify_identity_admin),
|
||||
def _normalize_referrer_code(value: str | None, fallback_tg_id: int | None) -> int | None:
|
||||
raw = str(value or "").strip()
|
||||
if raw:
|
||||
if "/referral/" in raw:
|
||||
raw = raw.split("/referral/", 1)[-1]
|
||||
if "start=referral_" in raw:
|
||||
raw = raw.split("start=referral_", 1)[-1]
|
||||
raw = raw.split("?", 1)[0].split("#", 1)[0].strip()
|
||||
parsed = decode_referral_code(raw)
|
||||
if parsed is not None:
|
||||
return parsed
|
||||
if fallback_tg_id is not None and int(fallback_tg_id) > 0:
|
||||
return int(fallback_tg_id)
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_public_base_url(request: Request) -> str:
|
||||
origin = str(request.headers.get("origin") or "").strip()
|
||||
if origin.startswith("http://") or origin.startswith("https://"):
|
||||
return origin.rstrip("/")
|
||||
referer = str(request.headers.get("referer") or request.headers.get("referrer") or "").strip()
|
||||
if referer.startswith("http://") or referer.startswith("https://"):
|
||||
parsed = urlsplit(referer)
|
||||
if parsed.scheme and parsed.netloc:
|
||||
return f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
|
||||
forwarded_host = str(request.headers.get("x-forwarded-host") or "").strip()
|
||||
host = forwarded_host or str(request.headers.get("host") or "").strip()
|
||||
forwarded_proto = str(request.headers.get("x-forwarded-proto") or "").split(",", 1)[0].strip().lower()
|
||||
scheme = forwarded_proto if forwarded_proto in {"http", "https"} else request.url.scheme
|
||||
if host:
|
||||
return f"{scheme}://{host}".rstrip("/")
|
||||
return str(request.base_url).rstrip("/")
|
||||
|
||||
|
||||
@router.post("/apply", response_model=ReferralApplyResponse, tags=["Referrals"])
|
||||
async def apply_referral(
|
||||
body: ReferralApplyRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
"""Удаляет одну связь реферала по паре referrer/referred."""
|
||||
result = await session.execute(
|
||||
select(Referral).where(Referral.referrer_tg_id == referrer_tg_id, Referral.referred_tg_id == referred_tg_id)
|
||||
if not bool(BUTTONS_CONFIG.get("REFERRAL_BUTTON_ENABLED", REFERRAL_BUTTON)):
|
||||
raise HTTPException(status_code=403, detail="Реферальная программа отключена")
|
||||
billing_uid = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
referrer_legacy = _normalize_referrer_code(body.referrer_code, body.referrer_tg_id)
|
||||
if referrer_legacy is None:
|
||||
raise HTTPException(status_code=400, detail="Приглашение недействительно")
|
||||
referrer_u = await resolve_user_optional(session, referrer_legacy)
|
||||
if referrer_u is None:
|
||||
raise HTTPException(status_code=400, detail="Приглашение недействительно")
|
||||
if billing_uid == referrer_u.id:
|
||||
raise HTTPException(status_code=400, detail="Нельзя использовать собственную ссылку")
|
||||
if await get_referral_by_referred_id(session, billing_uid):
|
||||
raise HTTPException(status_code=409, detail="Реферальная связь уже сохранена")
|
||||
await add_referral(session, billing_uid, referrer_u.id)
|
||||
referred_u = await resolve_user_optional(session, billing_uid)
|
||||
return ReferralApplyResponse(
|
||||
ok=True,
|
||||
message="Приглашение применено",
|
||||
referrer_code=str(referrer_u.id),
|
||||
referrer_user_id=int(referrer_u.id),
|
||||
referrer_tg_id=referrer_u.tg_id,
|
||||
referred_user_id=int(billing_uid),
|
||||
referred_tg_id=referred_u.tg_id if referred_u is not None else None,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/top", response_model=ReferralTopResponse, tags=["Referrals"])
|
||||
async def referral_top(
|
||||
limit: int = Query(5, ge=1, le=20),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
if not bool(BUTTONS_CONFIG.get("REFERRAL_BUTTON_ENABLED", REFERRAL_BUTTON)):
|
||||
raise HTTPException(status_code=403, detail="Реферальная программа отключена")
|
||||
if not bool(BUTTONS_CONFIG.get("TOP_REFERRAL_BUTTON_ENABLE", TOP_REFERRAL_BUTTON)):
|
||||
raise HTTPException(status_code=403, detail="Топ рефералов отключен в настройках")
|
||||
billing_uid = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
user_referral_count = int(await get_user_referral_count(session, billing_uid))
|
||||
user_position = int(await get_referral_position(session, user_referral_count)) if user_referral_count > 0 else None
|
||||
top_rows = await get_top_referrals(session, limit=limit)
|
||||
top: list[ReferralTopEntryResponse] = []
|
||||
for index, row in enumerate(top_rows, 1):
|
||||
referrer_user_id = int(row.get("referrer_user_id") or 0)
|
||||
referrals_count = int(row.get("referral_count") or 0)
|
||||
display_id = encode_referral_code(referrer_user_id)
|
||||
top.append(
|
||||
ReferralTopEntryResponse(
|
||||
position=index,
|
||||
referrer_user_id=referrer_user_id,
|
||||
referrals_count=referrals_count,
|
||||
display_id=display_id,
|
||||
)
|
||||
)
|
||||
return ReferralTopResponse(
|
||||
user_referrals_count=user_referral_count,
|
||||
user_position=user_position,
|
||||
top=top,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/qr", response_model=ReferralQrResponse, tags=["Referrals"])
|
||||
async def referral_qr(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
if not bool(BUTTONS_CONFIG.get("REFERRAL_BUTTON_ENABLED", REFERRAL_BUTTON)):
|
||||
raise HTTPException(status_code=403, detail="Реферальная программа отключена")
|
||||
if not bool(BUTTONS_CONFIG.get("REFERRAL_QR_BUTTON_ENABLE", REFERRAL_QR)):
|
||||
raise HTTPException(status_code=403, detail="QR реферальной ссылки отключен в настройках")
|
||||
billing_uid = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
base_url = _resolve_public_base_url(request)
|
||||
referral_link = f"{base_url}/referral/{encode_referral_code(int(billing_uid))}"
|
||||
qr = qrcode.QRCode(version=1, box_size=10, border=4)
|
||||
qr.add_data(referral_link)
|
||||
qr.make(fit=True)
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
buffer = BytesIO()
|
||||
img.save(buffer, format="PNG")
|
||||
image_data = b64encode(buffer.getvalue()).decode("ascii")
|
||||
return ReferralQrResponse(
|
||||
ok=True,
|
||||
link=referral_link,
|
||||
image_data_url=f"data:image/png;base64,{image_data}",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/conditions", response_model=ReferralConditionsResponse, tags=["Referrals"])
|
||||
async def referral_conditions(
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
if not bool(BUTTONS_CONFIG.get("REFERRAL_BUTTON_ENABLED", REFERRAL_BUTTON)):
|
||||
raise HTTPException(status_code=403, detail="Реферальная программа отключена")
|
||||
del identity
|
||||
level_lines: list[str] = []
|
||||
for level in sorted(REFERRAL_BONUS_PERCENTAGES.keys()):
|
||||
value = REFERRAL_BONUS_PERCENTAGES[level]
|
||||
if isinstance(value, float):
|
||||
label = f"{int(value * 100)}% от суммы оплаты"
|
||||
else:
|
||||
label = f"{float(value):g} RUB"
|
||||
level_lines.append(f"{level} уровень: {label}")
|
||||
one_time_mode = bool(CHECK_REFERRAL_REWARD_ISSUED)
|
||||
bonus_mode = "one_time" if one_time_mode else "each_payment"
|
||||
bonus_mode_label = "Бонус за первую успешную оплату реферала" if one_time_mode else "Бонус за каждую успешную оплату реферала"
|
||||
rules = [
|
||||
"Бонус начисляется только за реальных приглашённых пользователей.",
|
||||
"Нельзя использовать собственную реферальную ссылку.",
|
||||
"Реферальную связь можно применить только один раз.",
|
||||
"Размер бонуса зависит от уровня реферальной программы.",
|
||||
]
|
||||
return ReferralConditionsResponse(
|
||||
title="Условия реферальной программы",
|
||||
summary=f"Режим начисления: {bonus_mode_label}.",
|
||||
bonus_mode=bonus_mode,
|
||||
bonus_mode_label=bonus_mode_label,
|
||||
level_lines=level_lines,
|
||||
rules=rules,
|
||||
)
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail="Referral not found")
|
||||
await session.delete(obj)
|
||||
await session.commit()
|
||||
return {"status": "deleted_one"}
|
||||
|
||||
+138
-1
@@ -1,10 +1,63 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from config import PROJECT_NAME, USERNAME_BOT
|
||||
from config import (
|
||||
BALANCE_BUTTON,
|
||||
CAPTCHA_ENABLE,
|
||||
CHANNEL_EXISTS,
|
||||
CHANNEL_REQUIRED,
|
||||
DONATIONS_ENABLE,
|
||||
GIFT_BUTTON,
|
||||
HAPP_CRYPTOLINK,
|
||||
HWID_RESET_BUTTON,
|
||||
INSTRUCTIONS_BUTTON,
|
||||
PROJECT_NAME,
|
||||
REFERRAL_BUTTON,
|
||||
REFERRAL_QR,
|
||||
REMNAWAVE_WEBAPP,
|
||||
REMNAWAVE_WEBAPP_OPEN_IN_BROWSER,
|
||||
TOP_REFERRAL_BUTTON,
|
||||
TRIAL_TIME_DISABLE,
|
||||
USE_COUNTRY_SELECTION,
|
||||
USERNAME_BOT,
|
||||
TELEGRAM_WEBAPP_DIRECT_LINK,
|
||||
TELEGRAM_WEBAPP_SHORT_NAME,
|
||||
)
|
||||
from core.bootstrap import BUTTONS_CONFIG, MODES_CONFIG, MONEY_CONFIG, PAYMENTS_CONFIG
|
||||
from core.settings.web_config import WEB_CONFIG
|
||||
from core.settings.money_config import get_currency_mode
|
||||
from services.payments.providers import PROVIDERS_BASE, TELEGRAM_ONLY_PROVIDER_IDS, WEB_LINK_PROVIDER_IDS
|
||||
|
||||
router = APIRouter(tags=["Root"])
|
||||
|
||||
|
||||
def _telegram_web_app_return_base() -> str | None:
|
||||
direct = str(TELEGRAM_WEBAPP_DIRECT_LINK or "").strip().rstrip("/")
|
||||
if direct:
|
||||
if direct.lower().startswith("http://"):
|
||||
direct = "https://" + direct[7:]
|
||||
if direct.lower().startswith("https://t.me/"):
|
||||
return direct
|
||||
bot = USERNAME_BOT.replace("@", "").strip()
|
||||
sn = str(TELEGRAM_WEBAPP_SHORT_NAME or "").strip()
|
||||
if bot and sn:
|
||||
return f"https://t.me/{bot}/{sn}"
|
||||
if bot:
|
||||
return f"https://t.me/{bot}"
|
||||
return None
|
||||
|
||||
|
||||
def _partner_feature_enabled() -> bool:
|
||||
try:
|
||||
from modules.partner_program import settings as partner_settings
|
||||
except Exception:
|
||||
return False
|
||||
for key in ("PARTNER_PROGRAM_ENABLED", "PARTNER_BUTTON_ENABLED", "PARTNER_ENABLED"):
|
||||
value = getattr(partner_settings, key, None)
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return True
|
||||
|
||||
|
||||
@router.get("/api", include_in_schema=False)
|
||||
async def root():
|
||||
return {"message": "SoloBot API v2", "docs": "/api/docs"}
|
||||
@@ -22,3 +75,87 @@ async def telegram_widget_bot():
|
||||
"bot_username": USERNAME_BOT.replace("@", ""),
|
||||
"project_name": (PROJECT_NAME or "Solo").strip() if isinstance(PROJECT_NAME, str) else "Solo",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/site-config", include_in_schema=True)
|
||||
async def site_config():
|
||||
"""Настройки витрины и кабинета для веб-клиента (флаги из runtime-конфигов бота)."""
|
||||
bot_username = USERNAME_BOT.replace("@", "").strip()
|
||||
pay_flags = {name: bool(PAYMENTS_CONFIG.get(name)) for name in PROVIDERS_BASE}
|
||||
any_pay = any(pay_flags.values())
|
||||
web_link_provider_ids = [provider_id for provider_id in WEB_LINK_PROVIDER_IDS if pay_flags.get(provider_id, False)]
|
||||
telegram_only_provider_ids = [
|
||||
provider_id for provider_id in TELEGRAM_ONLY_PROVIDER_IDS if pay_flags.get(provider_id, False)
|
||||
]
|
||||
currency_mode, currency_one_screen = get_currency_mode()
|
||||
try:
|
||||
cb_raw = MONEY_CONFIG.get("CASHBACK", 0)
|
||||
cashback_percent = float(cb_raw) if cb_raw not in (None, False) else 0.0
|
||||
except (TypeError, ValueError):
|
||||
cashback_percent = 0.0
|
||||
|
||||
webapp_short = str(TELEGRAM_WEBAPP_SHORT_NAME or "").strip() or None
|
||||
webapp_return_base = _telegram_web_app_return_base()
|
||||
return {
|
||||
"bot_username": bot_username or None,
|
||||
"telegram_web_app_short_name": webapp_short,
|
||||
"telegram_web_app_return_base": webapp_return_base,
|
||||
"project_name": (PROJECT_NAME or "Solo").strip() if isinstance(PROJECT_NAME, str) else "Solo",
|
||||
"site_mode": str(WEB_CONFIG.get("SITE_MODE", "full")).strip() or "full",
|
||||
"auth": {
|
||||
"telegram_login_enabled": bool(bot_username),
|
||||
"email_code_login_enabled": bool(MODES_CONFIG.get("WEB_EMAIL_CODE_LOGIN_ENABLED", True)),
|
||||
},
|
||||
"mobile": {
|
||||
"prefer_mini_app_on_telegram_mobile": bool(
|
||||
MODES_CONFIG.get("PREFER_MINI_APP_ON_TELEGRAM_MOBILE", False)
|
||||
),
|
||||
},
|
||||
"features": {
|
||||
"channel_enabled": bool(BUTTONS_CONFIG.get("CHANNEL_BUTTON_ENABLE", CHANNEL_EXISTS)),
|
||||
"donations_enabled": bool(BUTTONS_CONFIG.get("DONATIONS_BUTTON_ENABLE", DONATIONS_ENABLE)),
|
||||
"balance_enabled": bool(BUTTONS_CONFIG.get("BALANCE_BUTTON_ENABLE", BALANCE_BUTTON)),
|
||||
"referral_qr_enabled": bool(BUTTONS_CONFIG.get("REFERRAL_QR_BUTTON_ENABLE", REFERRAL_QR)),
|
||||
"instructions_enabled": bool(BUTTONS_CONFIG.get("INSTRUCTIONS_BUTTON_ENABLE", INSTRUCTIONS_BUTTON)),
|
||||
"gift_enabled": bool(BUTTONS_CONFIG.get("GIFT_BUTTON_ENABLE", GIFT_BUTTON)),
|
||||
"referral_enabled": bool(BUTTONS_CONFIG.get("REFERRAL_BUTTON_ENABLED", REFERRAL_BUTTON)),
|
||||
"top_referral_enabled": bool(BUTTONS_CONFIG.get("TOP_REFERRAL_BUTTON_ENABLE", TOP_REFERRAL_BUTTON)),
|
||||
"coupon_enabled": bool(BUTTONS_CONFIG.get("COUPON_BUTTON_ENABLE", True)),
|
||||
"qr_subscription_enabled": bool(MODES_CONFIG.get("HAPP_CRYPTOLINK_ENABLED", HAPP_CRYPTOLINK)),
|
||||
"hwid_reset_enabled": bool(BUTTONS_CONFIG.get("HWID_RESET_BUTTON_ENABLE", HWID_RESET_BUTTON)),
|
||||
"country_selection_enabled": bool(
|
||||
MODES_CONFIG.get("COUNTRY_SELECTION_ENABLED", USE_COUNTRY_SELECTION)
|
||||
),
|
||||
"captcha_enabled": bool(MODES_CONFIG.get("CAPTCHA_ENABLED", CAPTCHA_ENABLE)),
|
||||
"channel_check_enabled": bool(MODES_CONFIG.get("CHANNEL_CHECK_ENABLED", CHANNEL_REQUIRED)),
|
||||
"trial_enabled": not bool(MODES_CONFIG.get("TRIAL_TIME_DISABLED", TRIAL_TIME_DISABLE)),
|
||||
"mini_app_enabled": bool(MODES_CONFIG.get("REMNAWAVE_WEBAPP_ENABLED", REMNAWAVE_WEBAPP)),
|
||||
"mini_app_open_in_browser": bool(
|
||||
MODES_CONFIG.get("REMNAWAVE_WEBAPP_OPEN_IN_BROWSER", REMNAWAVE_WEBAPP_OPEN_IN_BROWSER)
|
||||
),
|
||||
"partner_enabled": bool(_partner_feature_enabled()),
|
||||
},
|
||||
"payments": {
|
||||
"any_enabled": any_pay,
|
||||
"any_web_link_enabled": bool(web_link_provider_ids),
|
||||
"any_telegram_only_enabled": bool(telegram_only_provider_ids),
|
||||
"web_link_provider_ids": web_link_provider_ids,
|
||||
"telegram_only_provider_ids": telegram_only_provider_ids,
|
||||
"yookassa_enabled": pay_flags.get("YOOKASSA", False),
|
||||
"yoomoney_enabled": pay_flags.get("YOOMONEY", False),
|
||||
"robokassa_enabled": pay_flags.get("ROBOKASSA", False),
|
||||
"kassai_cards_enabled": pay_flags.get("KASSAI_CARDS", False),
|
||||
"kassai_sbp_enabled": pay_flags.get("KASSAI_SBP", False),
|
||||
"tribute_enabled": pay_flags.get("TRIBUTE", False),
|
||||
"heleket_enabled": pay_flags.get("HELEKET", False),
|
||||
"cryptobot_enabled": pay_flags.get("CRYPTOBOT", False),
|
||||
"freekassa_enabled": pay_flags.get("FREEKASSA", False),
|
||||
"stars_enabled": pay_flags.get("STARS", False),
|
||||
},
|
||||
"money": {
|
||||
"currency_mode": currency_mode,
|
||||
"currency_one_screen": currency_one_screen,
|
||||
"cashback_enabled": cashback_percent > 0,
|
||||
"cashback_percent": cashback_percent,
|
||||
},
|
||||
}
|
||||
|
||||
+378
-11
@@ -1,15 +1,62 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from datetime import datetime, timedelta
|
||||
from math import ceil
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from pytz import timezone as tz_moscow
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session
|
||||
from api.depends import get_session, validate_redirect_url, verify_identity_token
|
||||
from api.v2.base_crud import generate_crud_router
|
||||
from api.v2.routes.coupon_pricing import resolve_percent_coupon_pricing
|
||||
from api.v2.schemas import TariffBase, TariffResponse, TariffUpdate
|
||||
from api.v2.schemas.tariffs import TariffGroup, TariffPublic
|
||||
from api.v2.base_crud import generate_crud_router
|
||||
from api.v2.schemas.web_public import (
|
||||
TariffConfigPriceResponse,
|
||||
TariffPurchaseRequest,
|
||||
TariffPurchaseResponse,
|
||||
)
|
||||
from core.bootstrap import PAYMENTS_CONFIG
|
||||
from core.redis_cache import cache_get, cache_key, cache_set
|
||||
from database import (
|
||||
get_balance,
|
||||
identities as idb,
|
||||
)
|
||||
from database.coupons import mark_coupon_used
|
||||
from database.models import Tariff
|
||||
from database.tariffs import get_tariff_by_id
|
||||
from database.temporary_data import create_temporary_data
|
||||
from services.keys import create_vpn_key_headless
|
||||
from logger import logger
|
||||
from services.payments.payment_links import PaymentLinkRequest, create_payment_link
|
||||
from services.payments.providers import WEB_LINK_PROVIDER_IDS
|
||||
from services.tariffs import calculate_config_price
|
||||
|
||||
|
||||
def _tariff_to_public(t: Tariff) -> TariffPublic:
|
||||
dev_opts = getattr(t, "device_options", None)
|
||||
tr_opts = getattr(t, "traffic_options_gb", None)
|
||||
device_options: list[int] | None = None
|
||||
traffic_options_gb: list[int] | None = None
|
||||
if isinstance(dev_opts, list):
|
||||
device_options = []
|
||||
for x in dev_opts:
|
||||
try:
|
||||
device_options.append(int(x))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if not device_options:
|
||||
device_options = None
|
||||
if isinstance(tr_opts, list):
|
||||
traffic_options_gb = []
|
||||
for x in tr_opts:
|
||||
try:
|
||||
traffic_options_gb.append(int(x))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if not traffic_options_gb:
|
||||
traffic_options_gb = None
|
||||
return TariffPublic(
|
||||
id=t.id,
|
||||
name=t.name or "",
|
||||
@@ -21,18 +68,57 @@ def _tariff_to_public(t: Tariff) -> TariffPublic:
|
||||
subgroup_title=t.subgroup_title,
|
||||
sort_order=t.sort_order,
|
||||
vless=bool(getattr(t, "vless", False)),
|
||||
configurable=bool(getattr(t, "configurable", False)),
|
||||
device_options=device_options,
|
||||
traffic_options_gb=traffic_options_gb,
|
||||
)
|
||||
|
||||
|
||||
public_router = APIRouter()
|
||||
|
||||
|
||||
def _resolve_public_base_url(request: Request) -> str:
|
||||
origin = str(request.headers.get("origin") or "").strip()
|
||||
if origin.startswith(("http://", "https://")):
|
||||
return origin.rstrip("/")
|
||||
referer = str(request.headers.get("referer") or request.headers.get("referrer") or "").strip()
|
||||
if referer.startswith(("http://", "https://")):
|
||||
parsed = urlsplit(referer)
|
||||
if parsed.scheme and parsed.netloc:
|
||||
return f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
|
||||
forwarded_host = str(request.headers.get("x-forwarded-host") or "").strip()
|
||||
host = forwarded_host or str(request.headers.get("host") or "").strip()
|
||||
forwarded_proto = str(request.headers.get("x-forwarded-proto") or "").split(",", 1)[0].strip().lower()
|
||||
scheme = forwarded_proto if forwarded_proto in {"http", "https"} else request.url.scheme
|
||||
if host:
|
||||
return f"{scheme}://{host}".rstrip("/")
|
||||
return str(request.base_url).rstrip("/")
|
||||
|
||||
|
||||
def _resolve_default_web_payment_provider() -> str | None:
|
||||
for provider_id in WEB_LINK_PROVIDER_IDS:
|
||||
if bool(PAYMENTS_CONFIG.get(provider_id)):
|
||||
return provider_id
|
||||
return WEB_LINK_PROVIDER_IDS[0] if WEB_LINK_PROVIDER_IDS else None
|
||||
|
||||
|
||||
def _public_tariffs_cache_key(
|
||||
group_code: str | None,
|
||||
tariff_ids: str | None,
|
||||
filter_vless: str | None,
|
||||
) -> str:
|
||||
normalized_group = (group_code or "").strip().lower()
|
||||
normalized_ids = ",".join(part.strip() for part in (tariff_ids or "").split(",") if part.strip())
|
||||
normalized_vless = (filter_vless or "").strip().lower()
|
||||
return cache_key("tariffs_public", normalized_group or "-", normalized_ids or "-", normalized_vless or "-")
|
||||
|
||||
|
||||
@public_router.get("/groups", response_model=list[TariffGroup])
|
||||
async def get_tariff_groups(session: AsyncSession = Depends(get_session)):
|
||||
"""Публичный список групп тарифов — уникальные значения колонки group_code."""
|
||||
q = (
|
||||
select(Tariff.group_code)
|
||||
.where(Tariff.is_active == True, Tariff.group_code.isnot(None), Tariff.group_code != "")
|
||||
.where(Tariff.is_active is True, Tariff.group_code.isnot(None), Tariff.group_code != "")
|
||||
.distinct()
|
||||
.order_by(Tariff.group_code)
|
||||
)
|
||||
@@ -52,23 +138,304 @@ async def get_tariffs_public(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Публичный список активных тарифов (без авторизации)."""
|
||||
q = select(Tariff).where(Tariff.is_active == True).order_by(Tariff.sort_order.asc().nulls_last(), Tariff.price_rub.asc())
|
||||
cache_token = _public_tariffs_cache_key(group_code, tariff_ids, filter_vless)
|
||||
cached = await cache_get(cache_token)
|
||||
if isinstance(cached, list):
|
||||
return cached
|
||||
|
||||
q = select(Tariff).where(Tariff.is_active is True).order_by(Tariff.sort_order.asc().nulls_last(), Tariff.price_rub.asc())
|
||||
if tariff_ids:
|
||||
try:
|
||||
ids = [int(x.strip()) for x in tariff_ids.split(",") if x.strip()]
|
||||
if ids:
|
||||
q = q.where(Tariff.id.in_(ids))
|
||||
if not ids:
|
||||
return []
|
||||
q = q.where(Tariff.id.in_(ids))
|
||||
except ValueError:
|
||||
pass
|
||||
raise HTTPException(status_code=422, detail="Некорректный параметр tariff_ids")
|
||||
elif group_code:
|
||||
q = q.where(Tariff.group_code == group_code)
|
||||
if filter_vless == "router":
|
||||
q = q.where(Tariff.vless == True)
|
||||
q = q.where(Tariff.vless is True)
|
||||
elif filter_vless == "app":
|
||||
q = q.where(Tariff.vless == False)
|
||||
q = q.where(Tariff.vless is False)
|
||||
result = await session.execute(q)
|
||||
rows = result.scalars().all()
|
||||
return [_tariff_to_public(t) for t in rows]
|
||||
payload = [_tariff_to_public(t).model_dump() for t in rows]
|
||||
await cache_set(cache_token, payload, 30)
|
||||
return payload
|
||||
|
||||
|
||||
@public_router.get("/config-price", response_model=TariffConfigPriceResponse)
|
||||
async def get_tariff_config_price(
|
||||
tariff_id: int = Query(..., ge=1),
|
||||
selected_device_limit: int | None = Query(None),
|
||||
selected_traffic_gb: int | None = Query(None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
tariff = await get_tariff_by_id(session, tariff_id)
|
||||
if not tariff or not tariff.get("is_active", True):
|
||||
raise HTTPException(status_code=404, detail="Тариф не найден")
|
||||
price = int(calculate_config_price(tariff, selected_device_limit, selected_traffic_gb))
|
||||
return TariffConfigPriceResponse(price_rub=price)
|
||||
|
||||
|
||||
user_tariff_router = APIRouter()
|
||||
|
||||
|
||||
@user_tariff_router.post("/purchase", response_model=TariffPurchaseResponse)
|
||||
async def purchase_tariff_with_balance(
|
||||
body: TariffPurchaseRequest,
|
||||
request: Request,
|
||||
preview: bool = Query(False),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
tg_id = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
tariff = await get_tariff_by_id(session, body.tariff_id)
|
||||
if not tariff or not tariff.get("is_active", True):
|
||||
raise HTTPException(status_code=404, detail="Тариф не найден")
|
||||
price = int(calculate_config_price(tariff, body.selected_device_limit, body.selected_traffic_gb))
|
||||
if price <= 0:
|
||||
raise HTTPException(status_code=400, detail="Некорректная цена тарифа")
|
||||
final_price, discount_rub, coupon_id, applied_coupon_code = await resolve_percent_coupon_pricing(
|
||||
session=session,
|
||||
billing_user_id=int(tg_id),
|
||||
base_price_rub=int(price),
|
||||
coupon_code=body.coupon_code,
|
||||
)
|
||||
balance = float(await get_balance(session, tg_id))
|
||||
duration = int(tariff.get("duration_days") or 0)
|
||||
if duration <= 0:
|
||||
raise HTTPException(status_code=400, detail="Некорректная длительность тарифа")
|
||||
required_amount = int(max(0, ceil(float(final_price) - balance)))
|
||||
if preview:
|
||||
return TariffPurchaseResponse(
|
||||
ok=True,
|
||||
message="Расчет обновлен",
|
||||
key_email=None,
|
||||
charged_rub=0,
|
||||
base_price_rub=int(price),
|
||||
discount_rub=int(discount_rub),
|
||||
final_price_rub=int(final_price),
|
||||
applied_coupon_code=applied_coupon_code,
|
||||
payment_required=required_amount > 0,
|
||||
required_amount_rub=int(required_amount),
|
||||
payment_id=None,
|
||||
payment_url=None,
|
||||
)
|
||||
if required_amount > 0:
|
||||
provider_id = str(body.provider_id or _resolve_default_web_payment_provider() or "").strip().upper()
|
||||
if not provider_id:
|
||||
raise HTTPException(status_code=503, detail="Нет доступных провайдеров оплаты")
|
||||
base_url = _resolve_public_base_url(request)
|
||||
success_url = validate_redirect_url(str(body.success_url or ""), f"{base_url}/payment-success")
|
||||
failure_url = validate_redirect_url(str(body.failure_url or ""), f"{base_url}/payment-failure")
|
||||
payment_request = PaymentLinkRequest(
|
||||
legacy_user_ref=int(tg_id),
|
||||
amount=required_amount,
|
||||
currency="RUB",
|
||||
provider_id=provider_id,
|
||||
success_url=success_url,
|
||||
failure_url=failure_url,
|
||||
metadata={
|
||||
"payment_flow": "tariff_purchase",
|
||||
"tariff_id": int(body.tariff_id),
|
||||
"selected_device_limit": body.selected_device_limit,
|
||||
"selected_traffic_gb": body.selected_traffic_gb,
|
||||
"selected_duration_days": int(duration),
|
||||
"selected_price_rub": int(final_price),
|
||||
"base_price_rub": int(price),
|
||||
"discount_rub": int(discount_rub),
|
||||
"applied_coupon_code": applied_coupon_code,
|
||||
"coupon_id": int(coupon_id) if coupon_id is not None else None,
|
||||
},
|
||||
)
|
||||
payment_result = await create_payment_link(session, payment_request)
|
||||
if not payment_result.success or not payment_result.payment_url or not payment_result.payment_id:
|
||||
raise HTTPException(status_code=400, detail=payment_result.error or "Не удалось создать ссылку оплаты")
|
||||
await create_temporary_data(
|
||||
session,
|
||||
int(tg_id),
|
||||
"waiting_for_payment",
|
||||
{
|
||||
"tariff_id": int(body.tariff_id),
|
||||
"required_amount": int(required_amount),
|
||||
"selected_price_rub": int(final_price),
|
||||
"selected_device_limit": body.selected_device_limit,
|
||||
"selected_traffic_limit_gb": body.selected_traffic_gb,
|
||||
"selected_duration_days": int(duration),
|
||||
"base_price_rub": int(price),
|
||||
"discount_rub": int(discount_rub),
|
||||
"applied_coupon_code": applied_coupon_code,
|
||||
"coupon_id": int(coupon_id) if coupon_id is not None else None,
|
||||
},
|
||||
)
|
||||
return TariffPurchaseResponse(
|
||||
ok=True,
|
||||
message="Требуется оплата для оформления подписки",
|
||||
key_email=None,
|
||||
charged_rub=0,
|
||||
base_price_rub=int(price),
|
||||
discount_rub=int(discount_rub),
|
||||
final_price_rub=int(final_price),
|
||||
applied_coupon_code=applied_coupon_code,
|
||||
payment_required=True,
|
||||
required_amount_rub=required_amount,
|
||||
payment_id=payment_result.payment_id,
|
||||
payment_url=payment_result.payment_url,
|
||||
)
|
||||
moscow_tz = tz_moscow("Europe/Moscow")
|
||||
expiry = datetime.now(moscow_tz) + timedelta(days=duration)
|
||||
try:
|
||||
await create_vpn_key_headless(
|
||||
session=session,
|
||||
tg_id=tg_id,
|
||||
expiry_time=expiry,
|
||||
plan=body.tariff_id,
|
||||
selected_device_limit=body.selected_device_limit,
|
||||
selected_traffic_gb=body.selected_traffic_gb,
|
||||
selected_price_rub=final_price,
|
||||
)
|
||||
if coupon_id is not None:
|
||||
await mark_coupon_used(session, int(coupon_id), int(tg_id))
|
||||
await session.commit()
|
||||
except Exception:
|
||||
logger.exception("web tariff purchase failed")
|
||||
raise HTTPException(status_code=500, detail="Не удалось оформить подписку") from None
|
||||
return TariffPurchaseResponse(
|
||||
ok=True,
|
||||
message="Подписка оформлена. Ключ в разделе «Мои ключи».",
|
||||
key_email=None,
|
||||
charged_rub=final_price,
|
||||
base_price_rub=int(price),
|
||||
discount_rub=int(discount_rub),
|
||||
final_price_rub=int(final_price),
|
||||
applied_coupon_code=applied_coupon_code,
|
||||
)
|
||||
|
||||
|
||||
@user_tariff_router.post("/trial", response_model=TariffPurchaseResponse)
|
||||
async def activate_trial(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
"""Активация триала (бесплатного или платного). Доступно 1 раз."""
|
||||
from database import get_trial, update_trial
|
||||
from database.tariffs import get_tariffs
|
||||
|
||||
tg_id = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
|
||||
trial_status = await get_trial(session, tg_id)
|
||||
if trial_status not in (0, -1):
|
||||
raise HTTPException(status_code=409, detail="Пробная подписка уже использована")
|
||||
|
||||
trial_tariffs = await get_tariffs(session, group_code="trial")
|
||||
if not trial_tariffs:
|
||||
raise HTTPException(status_code=404, detail="Пробный тариф не найден")
|
||||
|
||||
tariff = trial_tariffs[0]
|
||||
price = int(tariff.get("price_rub", 0) or 0)
|
||||
duration = int(tariff.get("duration_days") or 0)
|
||||
if duration <= 0:
|
||||
raise HTTPException(status_code=400, detail="Некорректная длительность триала")
|
||||
|
||||
if price <= 0:
|
||||
moscow_tz = tz_moscow("Europe/Moscow")
|
||||
expiry = datetime.now(moscow_tz) + timedelta(days=duration)
|
||||
try:
|
||||
await create_vpn_key_headless(
|
||||
session=session,
|
||||
tg_id=tg_id,
|
||||
expiry_time=expiry,
|
||||
plan=int(tariff["id"]),
|
||||
selected_price_rub=0,
|
||||
skip_balance_charge=True,
|
||||
is_trial=True,
|
||||
)
|
||||
await update_trial(session, tg_id, 1)
|
||||
except Exception:
|
||||
logger.exception("web trial activation failed")
|
||||
raise HTTPException(status_code=500, detail="Ошибка активации триала") from None
|
||||
return TariffPurchaseResponse(
|
||||
ok=True,
|
||||
message="Пробная подписка активирована!",
|
||||
charged_rub=0,
|
||||
base_price_rub=0,
|
||||
final_price_rub=0,
|
||||
)
|
||||
|
||||
balance = float(await get_balance(session, tg_id))
|
||||
required_amount = int(max(0, ceil(float(price) - balance)))
|
||||
|
||||
if required_amount <= 0:
|
||||
moscow_tz = tz_moscow("Europe/Moscow")
|
||||
expiry = datetime.now(moscow_tz) + timedelta(days=duration)
|
||||
try:
|
||||
await create_vpn_key_headless(
|
||||
session=session,
|
||||
tg_id=tg_id,
|
||||
expiry_time=expiry,
|
||||
plan=int(tariff["id"]),
|
||||
selected_price_rub=price,
|
||||
is_trial=True,
|
||||
)
|
||||
await update_trial(session, tg_id, 1)
|
||||
except Exception:
|
||||
logger.exception("web paid trial activation failed")
|
||||
raise HTTPException(status_code=500, detail="Ошибка активации триала") from None
|
||||
return TariffPurchaseResponse(
|
||||
ok=True,
|
||||
message="Пробная подписка активирована!",
|
||||
charged_rub=price,
|
||||
base_price_rub=price,
|
||||
final_price_rub=price,
|
||||
)
|
||||
|
||||
provider_id = str(_resolve_default_web_payment_provider() or "").strip().upper()
|
||||
if not provider_id:
|
||||
raise HTTPException(status_code=503, detail="Нет доступных провайдеров оплаты")
|
||||
base_url = _resolve_public_base_url(request)
|
||||
payment_request = PaymentLinkRequest(
|
||||
legacy_user_ref=int(tg_id),
|
||||
amount=required_amount,
|
||||
currency="RUB",
|
||||
provider_id=provider_id,
|
||||
success_url=f"{base_url}/payment-success",
|
||||
failure_url=f"{base_url}/payment-failure",
|
||||
metadata={
|
||||
"payment_flow": "trial_purchase",
|
||||
"tariff_id": int(tariff["id"]),
|
||||
"selected_price_rub": price,
|
||||
"selected_duration_days": duration,
|
||||
},
|
||||
)
|
||||
payment_result = await create_payment_link(session, payment_request)
|
||||
if not payment_result.success or not payment_result.payment_url or not payment_result.payment_id:
|
||||
raise HTTPException(status_code=400, detail=payment_result.error or "Не удалось создать ссылку оплаты")
|
||||
await create_temporary_data(
|
||||
session,
|
||||
int(tg_id),
|
||||
"waiting_for_payment",
|
||||
{
|
||||
"payment_flow": "trial_purchase",
|
||||
"tariff_id": int(tariff["id"]),
|
||||
"required_amount": required_amount,
|
||||
"selected_price_rub": price,
|
||||
"selected_duration_days": duration,
|
||||
},
|
||||
)
|
||||
return TariffPurchaseResponse(
|
||||
ok=True,
|
||||
message="Требуется оплата для активации пробной подписки",
|
||||
charged_rub=0,
|
||||
base_price_rub=price,
|
||||
final_price_rub=price,
|
||||
payment_required=True,
|
||||
required_amount_rub=required_amount,
|
||||
payment_id=payment_result.payment_id,
|
||||
payment_url=payment_result.payment_url,
|
||||
)
|
||||
|
||||
|
||||
router = generate_crud_router(
|
||||
|
||||
@@ -9,7 +9,8 @@ from api.v2.schemas import UserBase, UserResponse, UserUpdate
|
||||
from api.v2.base_crud import generate_crud_router
|
||||
from database import async_session_maker, delete_user_data, get_servers
|
||||
from database.models import Key, User
|
||||
from handlers.keys.operations import delete_key_from_cluster
|
||||
from database.access.resolution import resolve_user_optional
|
||||
from services.operations import delete_key_from_cluster
|
||||
from logger import logger
|
||||
|
||||
router = generate_crud_router(
|
||||
@@ -30,7 +31,10 @@ async def delete_user(
|
||||
):
|
||||
"""Удаляет пользователя и его ключи на серверах."""
|
||||
try:
|
||||
result = await session.execute(select(Key.email, Key.client_id).where(Key.tg_id == tg_id))
|
||||
u = await resolve_user_optional(session, tg_id)
|
||||
if u is None:
|
||||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||||
result = await session.execute(select(Key.email, Key.client_id).where(Key.user_id == u.id))
|
||||
key_records = result.all()
|
||||
|
||||
async with async_session_maker() as s:
|
||||
|
||||
+614
-42
@@ -1,20 +1,72 @@
|
||||
import re
|
||||
import uuid
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, delete
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_identity_admin
|
||||
from api.v2.schemas import WebPageResponse, WebPageUpdate, WebBlockResponse, WebTheme
|
||||
from api.v2.schemas.web import WebUploadResponse
|
||||
from database.models import WebPage, WebBlock, WebTheme as WebThemeModel
|
||||
from api.v2.schemas import WebBlockResponse, WebPageResponse, WebPageUpdate, WebTheme
|
||||
from api.v2.schemas.web import (
|
||||
WebPageVariantCreate,
|
||||
WebPageVariantSummary,
|
||||
WebPageVariantUpdate,
|
||||
WebPageVariantsResponse,
|
||||
WebUploadResponse,
|
||||
)
|
||||
from database.models import (
|
||||
WebBlock,
|
||||
WebCustomElementBuild,
|
||||
WebFlow,
|
||||
WebFlowEvent,
|
||||
WebPage,
|
||||
WebPageVariant,
|
||||
WebPageVariantBlock,
|
||||
WebTheme as WebThemeModel,
|
||||
)
|
||||
from logger import logger
|
||||
|
||||
|
||||
UPLOAD_DIR = Path("static/web_uploads")
|
||||
ALLOWED_EXTENSIONS = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".mp4", ".webm"})
|
||||
MAX_FILE_SIZE = 100 * 1024 * 1024
|
||||
|
||||
_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9\-]*$")
|
||||
|
||||
EXTENSION_CONTENT_TYPES: dict[str, frozenset[str]] = {
|
||||
".png": frozenset({"image/png"}),
|
||||
".jpg": frozenset({"image/jpeg"}),
|
||||
".jpeg": frozenset({"image/jpeg"}),
|
||||
".gif": frozenset({"image/gif"}),
|
||||
".webp": frozenset({"image/webp"}),
|
||||
".svg": frozenset({"image/svg+xml", "text/xml", "application/xml", "text/plain"}),
|
||||
".mp4": frozenset({"video/mp4"}),
|
||||
".webm": frozenset({"video/webm"}),
|
||||
}
|
||||
|
||||
|
||||
def _sanitize_svg(data: bytes) -> bytes:
|
||||
import re as _re
|
||||
text = data.decode("utf-8", errors="replace")
|
||||
text = _re.sub(r"<script[^>]*>.*?</script>", "", text, flags=_re.DOTALL | _re.IGNORECASE)
|
||||
text = _re.sub(r"<style[^>]*>.*?</style>", "", text, flags=_re.DOTALL | _re.IGNORECASE)
|
||||
text = _re.sub(r"\bon\w+\s*=\s*[\"'][^\"']*[\"']", "", text, flags=_re.IGNORECASE)
|
||||
text = _re.sub(r"\bon\w+\s*=\s*\S+", "", text, flags=_re.IGNORECASE)
|
||||
text = _re.sub(r"(?:href|xlink:href)\s*=\s*[\"']\s*javascript:[^\"']*[\"']", "", text, flags=_re.IGNORECASE)
|
||||
text = _re.sub(r"(?:href|xlink:href)\s*=\s*[\"']\s*data:\s*text/html[^\"']*[\"']", "", text, flags=_re.IGNORECASE)
|
||||
text = _re.sub(r"(?:href|xlink:href)\s*=\s*[\"']\s*vbscript:[^\"']*[\"']", "", text, flags=_re.IGNORECASE)
|
||||
text = _re.sub(r"<foreignObject[^>]*>.*?</foreignObject>", "", text, flags=_re.DOTALL | _re.IGNORECASE)
|
||||
text = _re.sub(r"<iframe[^>]*>.*?</iframe>", "", text, flags=_re.DOTALL | _re.IGNORECASE)
|
||||
text = _re.sub(r"<embed[^>]*>", "", text, flags=_re.IGNORECASE)
|
||||
text = _re.sub(r"<object[^>]*>.*?</object>", "", text, flags=_re.DOTALL | _re.IGNORECASE)
|
||||
return text.encode("utf-8")
|
||||
|
||||
|
||||
router = APIRouter(tags=["Web"])
|
||||
|
||||
|
||||
@@ -22,7 +74,47 @@ class WebPagesListResponse(BaseModel):
|
||||
slugs: list[str]
|
||||
|
||||
|
||||
KNOWN_PAGE_SLUGS = ["landing", "tariffs", "faq", "login", "dashboard"]
|
||||
KNOWN_PAGE_SLUGS = [
|
||||
"landing",
|
||||
"tariffs",
|
||||
"faq",
|
||||
"login",
|
||||
"dashboard",
|
||||
"checkout",
|
||||
"gift-entry",
|
||||
"referral-entry",
|
||||
"partner-entry",
|
||||
"payment-success",
|
||||
"payment-failure",
|
||||
"dashboard-keys",
|
||||
"dashboard-profile",
|
||||
"dashboard-instructions",
|
||||
"dashboard-referrals",
|
||||
]
|
||||
|
||||
DEFAULT_VARIANT_KEY = "default"
|
||||
DEFAULT_VARIANT_NAME = "Основной"
|
||||
|
||||
|
||||
def _normalize_variant_key(value: str | None) -> str:
|
||||
raw = (value or "").strip().lower()
|
||||
normalized = re.sub(r"[^a-z0-9]+", "-", raw).strip("-")
|
||||
if not normalized:
|
||||
return DEFAULT_VARIANT_KEY
|
||||
return normalized[:64].strip("-") or DEFAULT_VARIANT_KEY
|
||||
|
||||
|
||||
def _normalize_variant_name(value: str | None, fallback: str) -> str:
|
||||
name = (value or "").strip()
|
||||
return name[:255] if name else fallback
|
||||
|
||||
|
||||
def _variant_summary(row: WebPageVariant) -> WebPageVariantSummary:
|
||||
return WebPageVariantSummary(
|
||||
key=row.variant_key,
|
||||
name=row.name or row.variant_key,
|
||||
is_active=bool(row.is_active),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/web/pages", response_model=WebPagesListResponse)
|
||||
@@ -46,69 +138,296 @@ async def get_or_create_page(session: AsyncSession, slug: str) -> WebPage:
|
||||
return page
|
||||
|
||||
|
||||
@router.get("/api/web/pages/{slug}", response_model=WebPageResponse)
|
||||
async def get_web_page(
|
||||
slug: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
await get_or_create_page(session, slug)
|
||||
async def _list_variants(session: AsyncSession, slug: str) -> list[WebPageVariant]:
|
||||
result = await session.execute(
|
||||
select(WebPageVariant)
|
||||
.where(WebPageVariant.page_slug == slug)
|
||||
.order_by(WebPageVariant.is_active.desc(), WebPageVariant.created_at, WebPageVariant.variant_key)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def _get_theme_tokens_for_legacy_page(session: AsyncSession, slug: str) -> dict:
|
||||
theme_result = await session.execute(select(WebThemeModel).where(WebThemeModel.page_slug == slug))
|
||||
theme_row = theme_result.scalar_one_or_none()
|
||||
return dict(theme_row.tokens or {}) if theme_row else {}
|
||||
|
||||
|
||||
async def _get_legacy_blocks(session: AsyncSession, slug: str) -> list[WebBlock]:
|
||||
blocks_result = await session.execute(
|
||||
select(WebBlock).where(WebBlock.page_slug == slug).order_by(WebBlock.order, WebBlock.id)
|
||||
)
|
||||
blocks = [WebBlockResponse.model_validate(b) for b in blocks_result.scalars().all()]
|
||||
return list(blocks_result.scalars().all())
|
||||
|
||||
theme_result = await session.execute(select(WebThemeModel).where(WebThemeModel.page_slug == slug))
|
||||
theme_row = theme_result.scalar_one_or_none()
|
||||
theme = WebTheme(tokens=theme_row.tokens) if theme_row else None
|
||||
|
||||
return WebPageResponse(slug=slug, blocks=blocks, theme=theme)
|
||||
async def _ensure_page_variants(session: AsyncSession, slug: str) -> list[WebPageVariant]:
|
||||
await get_or_create_page(session, slug)
|
||||
variants = await _list_variants(session, slug)
|
||||
if variants:
|
||||
if not any(variant.is_active for variant in variants):
|
||||
variants[0].is_active = True
|
||||
await session.flush()
|
||||
variants = await _list_variants(session, slug)
|
||||
return variants
|
||||
|
||||
legacy_blocks = await _get_legacy_blocks(session, slug)
|
||||
theme_tokens = await _get_theme_tokens_for_legacy_page(session, slug)
|
||||
variant = WebPageVariant(
|
||||
page_slug=slug,
|
||||
variant_key=DEFAULT_VARIANT_KEY,
|
||||
name=DEFAULT_VARIANT_NAME,
|
||||
is_active=True,
|
||||
theme_tokens=theme_tokens,
|
||||
)
|
||||
session.add(variant)
|
||||
await session.flush()
|
||||
for legacy_block in legacy_blocks:
|
||||
session.add(
|
||||
WebPageVariantBlock(
|
||||
variant_id=variant.id,
|
||||
order=legacy_block.order,
|
||||
type=legacy_block.type,
|
||||
data=legacy_block.data,
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
return await _list_variants(session, slug)
|
||||
|
||||
|
||||
async def _resolve_variant(
|
||||
session: AsyncSession,
|
||||
slug: str,
|
||||
variant_key: str | None,
|
||||
) -> tuple[WebPageVariant, list[WebPageVariant]]:
|
||||
variants = await _ensure_page_variants(session, slug)
|
||||
desired_key = _normalize_variant_key(variant_key) if variant_key else ""
|
||||
current = None
|
||||
if desired_key:
|
||||
current = next((variant for variant in variants if variant.variant_key == desired_key), None)
|
||||
if current is None:
|
||||
raise HTTPException(404, "Вариант страницы не найден")
|
||||
else:
|
||||
current = next((variant for variant in variants if variant.is_active), variants[0])
|
||||
return current, variants
|
||||
|
||||
|
||||
async def _get_variant_blocks(session: AsyncSession, variant_id: str) -> list[WebBlockResponse]:
|
||||
blocks_result = await session.execute(
|
||||
select(WebPageVariantBlock)
|
||||
.where(WebPageVariantBlock.variant_id == variant_id)
|
||||
.order_by(WebPageVariantBlock.order, WebPageVariantBlock.id)
|
||||
)
|
||||
return [WebBlockResponse.model_validate(block) for block in blocks_result.scalars().all()]
|
||||
|
||||
|
||||
async def _build_page_response(
|
||||
session: AsyncSession,
|
||||
slug: str,
|
||||
current: WebPageVariant,
|
||||
variants: list[WebPageVariant] | None = None,
|
||||
) -> WebPageResponse:
|
||||
current_variants = variants or await _list_variants(session, slug)
|
||||
active = next((variant for variant in current_variants if variant.is_active), current)
|
||||
blocks = await _get_variant_blocks(session, current.id)
|
||||
theme = WebTheme(tokens=dict(current.theme_tokens or {}))
|
||||
return WebPageResponse(
|
||||
slug=slug,
|
||||
blocks=blocks,
|
||||
theme=theme,
|
||||
variant_key=current.variant_key,
|
||||
active_variant_key=active.variant_key,
|
||||
variants=[_variant_summary(variant) for variant in current_variants],
|
||||
)
|
||||
|
||||
|
||||
async def _set_active_variant(session: AsyncSession, slug: str, variant_key: str) -> list[WebPageVariant]:
|
||||
variants = await _list_variants(session, slug)
|
||||
matched = False
|
||||
for variant in variants:
|
||||
is_target = variant.variant_key == variant_key
|
||||
variant.is_active = is_target
|
||||
matched = matched or is_target
|
||||
if not matched:
|
||||
raise HTTPException(404, "Вариант страницы не найден")
|
||||
await session.flush()
|
||||
return await _list_variants(session, slug)
|
||||
|
||||
|
||||
def _generate_variant_key(existing_keys: set[str], requested_key: str | None, requested_name: str | None) -> str:
|
||||
base = _normalize_variant_key(requested_key or requested_name)
|
||||
if not base:
|
||||
base = DEFAULT_VARIANT_KEY
|
||||
if base not in existing_keys:
|
||||
return base
|
||||
suffix = 2
|
||||
while True:
|
||||
candidate = f"{base}-{suffix}"
|
||||
if candidate not in existing_keys:
|
||||
return candidate[:64]
|
||||
suffix += 1
|
||||
|
||||
|
||||
@router.get("/api/web/pages/{slug}", response_model=WebPageResponse)
|
||||
async def get_web_page(
|
||||
slug: str,
|
||||
variant: str | None = Query(default=None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
if not slug or len(slug) > 64 or not _SLUG_RE.match(slug):
|
||||
raise HTTPException(400, "Некорректный slug страницы")
|
||||
current, variants = await _resolve_variant(session, slug, variant)
|
||||
return await _build_page_response(session, slug, current, variants)
|
||||
|
||||
|
||||
@router.put("/api/web/pages/{slug}", response_model=WebPageResponse)
|
||||
async def update_web_page(
|
||||
slug: str,
|
||||
body: WebPageUpdate,
|
||||
variant: str | None = Query(default=None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_admin),
|
||||
):
|
||||
await get_or_create_page(session, slug)
|
||||
|
||||
await session.execute(delete(WebBlock).where(WebBlock.page_slug == slug))
|
||||
current, _ = await _resolve_variant(session, slug, variant)
|
||||
await session.execute(delete(WebPageVariantBlock).where(WebPageVariantBlock.variant_id == current.id))
|
||||
|
||||
for block in body.blocks:
|
||||
session.add(
|
||||
WebBlock(
|
||||
page_slug=slug,
|
||||
WebPageVariantBlock(
|
||||
variant_id=current.id,
|
||||
order=block.order,
|
||||
type=block.type,
|
||||
data=block.data,
|
||||
)
|
||||
)
|
||||
|
||||
theme_row = None
|
||||
if body.theme is not None:
|
||||
result = await session.execute(select(WebThemeModel).where(WebThemeModel.page_slug == slug))
|
||||
theme_row = result.scalar_one_or_none()
|
||||
if theme_row is None:
|
||||
theme_row = WebThemeModel(page_slug=slug, tokens=body.theme.tokens)
|
||||
session.add(theme_row)
|
||||
else:
|
||||
theme_row.tokens = body.theme.tokens
|
||||
current.theme_tokens = body.theme.tokens
|
||||
|
||||
await session.flush()
|
||||
refreshed_variants = await _list_variants(session, slug)
|
||||
refreshed_current = next((item for item in refreshed_variants if item.id == current.id), current)
|
||||
return await _build_page_response(session, slug, refreshed_current, refreshed_variants)
|
||||
|
||||
blocks_result = await session.execute(
|
||||
select(WebBlock).where(WebBlock.page_slug == slug).order_by(WebBlock.order, WebBlock.id)
|
||||
|
||||
@router.get("/api/web/pages/{slug}/variants", response_model=WebPageVariantsResponse)
|
||||
async def get_web_page_variants(
|
||||
slug: str,
|
||||
variant: str | None = Query(default=None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
current, variants = await _resolve_variant(session, slug, variant)
|
||||
active = next((item for item in variants if item.is_active), current)
|
||||
return WebPageVariantsResponse(
|
||||
slug=slug,
|
||||
active_variant_key=active.variant_key,
|
||||
current_variant_key=current.variant_key,
|
||||
variants=[_variant_summary(item) for item in variants],
|
||||
)
|
||||
blocks = [WebBlockResponse.model_validate(b) for b in blocks_result.scalars().all()]
|
||||
|
||||
if theme_row is None:
|
||||
theme_result = await session.execute(select(WebThemeModel).where(WebThemeModel.page_slug == slug))
|
||||
theme_row = theme_result.scalar_one_or_none()
|
||||
theme = WebTheme(tokens=theme_row.tokens) if theme_row else None
|
||||
|
||||
return WebPageResponse(slug=slug, blocks=blocks, theme=theme)
|
||||
@router.post("/api/web/pages/{slug}/variants", response_model=WebPageVariantsResponse)
|
||||
async def create_web_page_variant(
|
||||
slug: str,
|
||||
body: WebPageVariantCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_admin),
|
||||
):
|
||||
source_variant, variants = await _resolve_variant(session, slug, body.from_variant_key)
|
||||
existing_keys = {variant.variant_key for variant in variants}
|
||||
variant_key = _generate_variant_key(existing_keys, body.key, body.name)
|
||||
if variant_key in existing_keys:
|
||||
raise HTTPException(400, "Вариант с таким ключом уже существует")
|
||||
|
||||
variant_name = _normalize_variant_name(body.name, f"Вариант {len(variants) + 1}")
|
||||
new_variant = WebPageVariant(
|
||||
page_slug=slug,
|
||||
variant_key=variant_key,
|
||||
name=variant_name,
|
||||
is_active=False,
|
||||
theme_tokens=dict(source_variant.theme_tokens or {}),
|
||||
)
|
||||
session.add(new_variant)
|
||||
await session.flush()
|
||||
|
||||
source_blocks = await _get_variant_blocks(session, source_variant.id)
|
||||
for block in source_blocks:
|
||||
session.add(
|
||||
WebPageVariantBlock(
|
||||
variant_id=new_variant.id,
|
||||
order=block.order,
|
||||
type=block.type,
|
||||
data=block.data,
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
|
||||
refreshed = await _list_variants(session, slug)
|
||||
return WebPageVariantsResponse(
|
||||
slug=slug,
|
||||
active_variant_key=next((item.variant_key for item in refreshed if item.is_active), DEFAULT_VARIANT_KEY),
|
||||
current_variant_key=new_variant.variant_key,
|
||||
variants=[_variant_summary(item) for item in refreshed],
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/api/web/pages/{slug}/variants/{variant_key}", response_model=WebPageVariantsResponse)
|
||||
async def update_web_page_variant(
|
||||
slug: str,
|
||||
variant_key: str,
|
||||
body: WebPageVariantUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_admin),
|
||||
):
|
||||
current, variants = await _resolve_variant(session, slug, variant_key)
|
||||
if body.name is not None:
|
||||
current.name = _normalize_variant_name(body.name, current.name or current.variant_key)
|
||||
if body.make_active is True:
|
||||
variants = await _set_active_variant(session, slug, current.variant_key)
|
||||
current = next((item for item in variants if item.variant_key == current.variant_key), current)
|
||||
else:
|
||||
await session.flush()
|
||||
variants = await _list_variants(session, slug)
|
||||
|
||||
active = next((item for item in variants if item.is_active), current)
|
||||
return WebPageVariantsResponse(
|
||||
slug=slug,
|
||||
active_variant_key=active.variant_key,
|
||||
current_variant_key=current.variant_key,
|
||||
variants=[_variant_summary(item) for item in variants],
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/api/web/pages/{slug}/variants/{variant_key}", response_model=WebPageVariantsResponse)
|
||||
async def delete_web_page_variant(
|
||||
slug: str,
|
||||
variant_key: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_admin),
|
||||
):
|
||||
current, variants = await _resolve_variant(session, slug, variant_key)
|
||||
if len(variants) <= 1:
|
||||
raise HTTPException(400, "Нельзя удалить единственный вариант страницы")
|
||||
|
||||
replacement = next((item for item in variants if item.variant_key != current.variant_key), None)
|
||||
await session.execute(delete(WebPageVariant).where(WebPageVariant.id == current.id))
|
||||
await session.flush()
|
||||
|
||||
if current.is_active and replacement is not None:
|
||||
replacement_variants = await _set_active_variant(session, slug, replacement.variant_key)
|
||||
else:
|
||||
replacement_variants = await _list_variants(session, slug)
|
||||
|
||||
current_variant_key = replacement.variant_key if replacement is not None else DEFAULT_VARIANT_KEY
|
||||
active_variant_key = next(
|
||||
(item.variant_key for item in replacement_variants if item.is_active),
|
||||
current_variant_key,
|
||||
)
|
||||
return WebPageVariantsResponse(
|
||||
slug=slug,
|
||||
active_variant_key=active_variant_key,
|
||||
current_variant_key=current_variant_key,
|
||||
variants=[_variant_summary(item) for item in replacement_variants],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/web/upload", response_model=WebUploadResponse)
|
||||
@@ -125,18 +444,271 @@ async def upload_media(
|
||||
400,
|
||||
f"Разрешены только: {', '.join(sorted(ALLOWED_EXTENSIONS))}",
|
||||
)
|
||||
if file.content_type:
|
||||
allowed_types = EXTENSION_CONTENT_TYPES.get(ext)
|
||||
if allowed_types and file.content_type.lower() not in allowed_types:
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"Тип файла ({file.content_type}) не соответствует расширению ({ext})",
|
||||
)
|
||||
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
chunks: list[bytes] = []
|
||||
size = 0
|
||||
for chunk in file.file:
|
||||
size += len(chunk)
|
||||
if size > MAX_FILE_SIZE:
|
||||
raise HTTPException(400, f"Размер файла не более {MAX_FILE_SIZE // (1024*1024)} МБ")
|
||||
await file.seek(0)
|
||||
raise HTTPException(400, f"Размер файла не более {MAX_FILE_SIZE // (1024 * 1024)} МБ")
|
||||
chunks.append(chunk)
|
||||
name = f"{uuid.uuid4().hex}{ext}"
|
||||
path = UPLOAD_DIR / name
|
||||
file_data = b"".join(chunks)
|
||||
if ext == ".svg":
|
||||
file_data = _sanitize_svg(file_data)
|
||||
with open(path, "wb") as f:
|
||||
while chunk := await file.read(64 * 1024):
|
||||
f.write(chunk)
|
||||
f.write(file_data)
|
||||
url = f"/api/web/uploads/{name}"
|
||||
logger.info(
|
||||
"[WebUpload] admin={} file={} -> {} ({} bytes)",
|
||||
identity.id,
|
||||
file.filename,
|
||||
name,
|
||||
len(file_data),
|
||||
)
|
||||
return WebUploadResponse(url=url)
|
||||
|
||||
|
||||
# ── Custom Element Builds ──
|
||||
|
||||
|
||||
class CustomElementBuildCreate(BaseModel):
|
||||
label: str = ""
|
||||
slug: str = ""
|
||||
runtime: str = "react-component"
|
||||
source_kind: str = "inline-code"
|
||||
source_value: str = ""
|
||||
export_name: str = "default"
|
||||
props_schema_text: str = ""
|
||||
sample_props_text: str = ""
|
||||
events_text: str = ""
|
||||
notes: str = ""
|
||||
|
||||
|
||||
class CustomElementBuildUpdate(BaseModel):
|
||||
status: str | None = None
|
||||
summary: str | None = None
|
||||
next_steps: list[str] | None = None
|
||||
artifact: dict | None = None
|
||||
upload_meta: dict | None = None
|
||||
worker_id: str | None = None
|
||||
|
||||
|
||||
def _build_to_dict(b: WebCustomElementBuild) -> dict:
|
||||
return {
|
||||
"id": b.id,
|
||||
"label": b.label,
|
||||
"slug": b.slug,
|
||||
"runtime": b.runtime,
|
||||
"sourceKind": b.source_kind,
|
||||
"sourceValue": b.source_value,
|
||||
"exportName": b.export_name,
|
||||
"propsSchemaText": b.props_schema_text,
|
||||
"samplePropsText": b.sample_props_text,
|
||||
"eventsText": b.events_text,
|
||||
"notes": b.notes,
|
||||
"status": b.status,
|
||||
"summary": b.summary,
|
||||
"nextSteps": b.next_steps or [],
|
||||
"artifact": b.artifact,
|
||||
"upload": b.upload_meta,
|
||||
"workerId": b.worker_id,
|
||||
"workerClaimedAt": b.worker_claimed_at.isoformat() if b.worker_claimed_at else None,
|
||||
"completedAt": b.completed_at.isoformat() if b.completed_at else None,
|
||||
"createdAt": b.created_at.isoformat() if b.created_at else None,
|
||||
"updatedAt": b.updated_at.isoformat() if b.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/custom-element-builds")
|
||||
async def list_custom_element_builds(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_identity=Depends(verify_identity_admin),
|
||||
):
|
||||
result = await session.execute(
|
||||
select(WebCustomElementBuild).order_by(WebCustomElementBuild.created_at.desc())
|
||||
)
|
||||
builds = result.scalars().all()
|
||||
return [_build_to_dict(b) for b in builds]
|
||||
|
||||
|
||||
@router.post("/custom-element-builds")
|
||||
async def create_custom_element_build(
|
||||
body: CustomElementBuildCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_identity=Depends(verify_identity_admin),
|
||||
):
|
||||
build = WebCustomElementBuild(
|
||||
id=str(uuid.uuid4()),
|
||||
label=body.label,
|
||||
slug=body.slug,
|
||||
runtime=body.runtime,
|
||||
source_kind=body.source_kind,
|
||||
source_value=body.source_value,
|
||||
export_name=body.export_name,
|
||||
props_schema_text=body.props_schema_text,
|
||||
sample_props_text=body.sample_props_text,
|
||||
events_text=body.events_text,
|
||||
notes=body.notes,
|
||||
status="queued",
|
||||
)
|
||||
session.add(build)
|
||||
return _build_to_dict(build)
|
||||
|
||||
|
||||
@router.get("/custom-element-builds/{build_id}")
|
||||
async def get_custom_element_build(
|
||||
build_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_identity=Depends(verify_identity_admin),
|
||||
):
|
||||
build = await session.get(WebCustomElementBuild, build_id)
|
||||
if not build:
|
||||
raise HTTPException(404, "Build not found")
|
||||
return _build_to_dict(build)
|
||||
|
||||
|
||||
@router.patch("/custom-element-builds/{build_id}")
|
||||
async def update_custom_element_build(
|
||||
build_id: str,
|
||||
body: CustomElementBuildUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_identity=Depends(verify_identity_admin),
|
||||
):
|
||||
build = await session.get(WebCustomElementBuild, build_id)
|
||||
if not build:
|
||||
raise HTTPException(404, "Build not found")
|
||||
if body.status is not None:
|
||||
build.status = body.status
|
||||
if body.summary is not None:
|
||||
build.summary = body.summary
|
||||
if body.next_steps is not None:
|
||||
build.next_steps = body.next_steps
|
||||
if body.artifact is not None:
|
||||
build.artifact = body.artifact
|
||||
if body.upload_meta is not None:
|
||||
build.upload_meta = body.upload_meta
|
||||
if body.worker_id is not None:
|
||||
build.worker_id = body.worker_id
|
||||
return _build_to_dict(build)
|
||||
|
||||
|
||||
@router.delete("/custom-element-builds/{build_id}")
|
||||
async def delete_custom_element_build(
|
||||
build_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_identity=Depends(verify_identity_admin),
|
||||
):
|
||||
build = await session.get(WebCustomElementBuild, build_id)
|
||||
if not build:
|
||||
raise HTTPException(404, "Build not found")
|
||||
await session.delete(build)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Flow Analytics ──
|
||||
|
||||
|
||||
class FlowEventBatch(BaseModel):
|
||||
events: list[dict]
|
||||
|
||||
|
||||
@router.post("/analytics/flow-events")
|
||||
async def ingest_flow_events(
|
||||
body: FlowEventBatch,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
from core.redis_cache import cache_incr_checked
|
||||
from api.v2.routes.auth._fallback_limiter import check_and_increment
|
||||
ip = (request.client.host if request.client else "") or "unknown"
|
||||
count, redis_ok = await cache_incr_checked(f"analytics_rate:{ip}", 60)
|
||||
if not redis_ok:
|
||||
count = check_and_increment(f"analytics_rate:{ip}", 60, 60)
|
||||
if count > 60:
|
||||
raise HTTPException(status_code=429, detail="Too many events")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
pass
|
||||
created = 0
|
||||
for raw in body.events[:100]:
|
||||
flow_id = str(raw.get("flowId", ""))
|
||||
node_id = str(raw.get("nodeId", ""))
|
||||
event_type = str(raw.get("eventType", ""))
|
||||
if not flow_id or not node_id or not event_type:
|
||||
continue
|
||||
ev = WebFlowEvent(
|
||||
id=str(uuid.uuid4()),
|
||||
flow_id=flow_id,
|
||||
node_id=node_id,
|
||||
node_type=str(raw.get("nodeType", "")),
|
||||
event_type=event_type,
|
||||
ab_variant=raw.get("abVariant") or None,
|
||||
device=raw.get("device") or None,
|
||||
locale=raw.get("locale") or None,
|
||||
authenticated=raw.get("authenticated"),
|
||||
event_metadata=raw.get("collectedDataSnapshot") or None,
|
||||
)
|
||||
session.add(ev)
|
||||
created += 1
|
||||
return {"ingested": created}
|
||||
|
||||
|
||||
@router.get("/analytics/flow-funnel/{flow_id}")
|
||||
async def get_flow_funnel(
|
||||
flow_id: str,
|
||||
days: int = Query(default=30, ge=1, le=365),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_identity=Depends(verify_identity_admin),
|
||||
):
|
||||
since = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(
|
||||
WebFlowEvent.node_id,
|
||||
WebFlowEvent.node_type,
|
||||
WebFlowEvent.event_type,
|
||||
func.count().label("cnt"),
|
||||
)
|
||||
.where(WebFlowEvent.flow_id == flow_id)
|
||||
.where(WebFlowEvent.created_at >= since)
|
||||
.group_by(WebFlowEvent.node_id, WebFlowEvent.node_type, WebFlowEvent.event_type)
|
||||
)
|
||||
).all()
|
||||
|
||||
nodes: dict[str, dict] = {}
|
||||
for node_id, node_type, event_type, cnt in rows:
|
||||
if node_id not in nodes:
|
||||
nodes[node_id] = {"nodeId": node_id, "nodeType": node_type, "entered": 0, "exited": 0, "completed": 0}
|
||||
if event_type == "flow_step_entered":
|
||||
nodes[node_id]["entered"] = cnt
|
||||
elif event_type == "flow_step_exited":
|
||||
nodes[node_id]["exited"] = cnt
|
||||
elif event_type == "flow_completed":
|
||||
nodes[node_id]["completed"] = cnt
|
||||
|
||||
flow = await session.get(WebFlow, flow_id)
|
||||
if flow and flow.nodes:
|
||||
node_order = {n["id"]: i for i, n in enumerate(flow.nodes) if isinstance(n, dict)}
|
||||
else:
|
||||
node_order = {}
|
||||
|
||||
funnel = sorted(nodes.values(), key=lambda n: node_order.get(n["nodeId"], 999))
|
||||
|
||||
for i, node in enumerate(funnel):
|
||||
prev_entered = funnel[i - 1]["entered"] if i > 0 else node["entered"]
|
||||
node["dropOff"] = round(
|
||||
(1 - node["entered"] / prev_entered) * 100, 1
|
||||
) if prev_entered > 0 else 0
|
||||
|
||||
return {"flowId": flow_id, "days": days, "funnel": funnel}
|
||||
|
||||
@@ -28,4 +28,13 @@ from api.v1.schemas import (
|
||||
)
|
||||
from api.v1.schemas.keys import KeyBase, KeyCreateRequest, KeyUpdate
|
||||
from api.v1.schemas.settings import SettingResponse, SettingUpsert
|
||||
from api.v2.schemas.web import WebBlockResponse, WebTheme, WebPageResponse, WebPageUpdate
|
||||
from api.v2.schemas.web import (
|
||||
WebBlockResponse,
|
||||
WebTheme,
|
||||
WebPageResponse,
|
||||
WebPageUpdate,
|
||||
WebPageVariantCreate,
|
||||
WebPageVariantSummary,
|
||||
WebPageVariantUpdate,
|
||||
WebPageVariantsResponse,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class EdgeConditionSchema(BaseModel):
|
||||
field: str
|
||||
operator: str
|
||||
value: Any = None
|
||||
|
||||
|
||||
class FlowEdgeSchema(BaseModel):
|
||||
id: str
|
||||
source: str
|
||||
target: str
|
||||
condition: EdgeConditionSchema | None = None
|
||||
label: str | None = None
|
||||
priority: int | None = None
|
||||
|
||||
|
||||
class FlowNodeSchema(BaseModel):
|
||||
id: str
|
||||
type: str
|
||||
label: str
|
||||
label_en: str | None = None
|
||||
enabled: bool = True
|
||||
page_slug: str | None = None
|
||||
config: dict = {}
|
||||
position: dict
|
||||
|
||||
|
||||
class FlowResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
nodes: list[FlowNodeSchema]
|
||||
edges: list[FlowEdgeSchema]
|
||||
entry_node_id: str | None
|
||||
version: int
|
||||
|
||||
|
||||
class FlowUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
nodes: list[FlowNodeSchema]
|
||||
edges: list[FlowEdgeSchema]
|
||||
entry_node_id: str | None = None
|
||||
|
||||
|
||||
class FlowCreate(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
nodes: list[FlowNodeSchema] = []
|
||||
edges: list[FlowEdgeSchema] = []
|
||||
entry_node_id: str | None = None
|
||||
@@ -13,6 +13,8 @@ class IdentityResponse(BaseModel):
|
||||
email: str | None
|
||||
tg_id: int | None
|
||||
is_admin: bool = False
|
||||
email_verified: bool = False
|
||||
password_set: bool = False
|
||||
created_at: datetime | None
|
||||
updated_at: datetime | None
|
||||
|
||||
@@ -23,11 +25,12 @@ class IdentityResponse(BaseModel):
|
||||
class RegisterByEmailRequest(BaseModel):
|
||||
email: str = Field(..., min_length=1)
|
||||
password: str = Field(..., min_length=8, description="Пароль (минимум 8 символов)")
|
||||
referral_code: str | None = Field(None, min_length=1)
|
||||
turnstile_token: str | None = Field(default=None, description="Cloudflare Turnstile CAPTCHA token")
|
||||
|
||||
|
||||
class RegisterResponse(BaseModel):
|
||||
identity_id: str
|
||||
token: str
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
@@ -35,13 +38,28 @@ class LoginRequest(BaseModel):
|
||||
password: str = Field(...)
|
||||
|
||||
|
||||
class SetPasswordRequest(BaseModel):
|
||||
password: str = Field(..., min_length=8, description="Новый пароль (минимум 8 символов)")
|
||||
password_confirm: str = Field(..., min_length=8)
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
current_password: str = Field(...)
|
||||
password: str = Field(..., min_length=8, description="Новый пароль (минимум 8 символов)")
|
||||
password_confirm: str = Field(..., min_length=8)
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
identity_id: str
|
||||
token: str
|
||||
|
||||
|
||||
class SendLoginCodeRequest(BaseModel):
|
||||
email: str = Field(..., min_length=1)
|
||||
allow_register: bool = Field(
|
||||
default=False,
|
||||
description="Если true и email новый — создать идентичность и отправить код (гостевой вход с сайта)",
|
||||
)
|
||||
turnstile_token: str | None = Field(default=None, description="Cloudflare Turnstile CAPTCHA token")
|
||||
|
||||
|
||||
class LoginByCodeRequest(BaseModel):
|
||||
@@ -49,6 +67,13 @@ class LoginByCodeRequest(BaseModel):
|
||||
code: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class ConfirmPasswordResetRequest(BaseModel):
|
||||
email: str = Field(..., min_length=1)
|
||||
code: str = Field(..., min_length=1)
|
||||
password: str = Field(..., min_length=8)
|
||||
password_confirm: str = Field(..., min_length=8)
|
||||
|
||||
|
||||
class LoginTelegramRequest(BaseModel):
|
||||
"""Данные от Telegram Login Widget (кнопка «Войти через Telegram»)."""
|
||||
|
||||
@@ -77,5 +102,14 @@ class IdentityAttachEmail(BaseModel):
|
||||
email: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class LinkEmailSendCodeRequest(BaseModel):
|
||||
email: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class LinkEmailConfirmRequest(BaseModel):
|
||||
email: str = Field(..., min_length=1)
|
||||
code: str = Field(..., min_length=1, max_length=16)
|
||||
|
||||
|
||||
class IdentityAttachTelegram(BaseModel):
|
||||
tg_id: int = Field(...)
|
||||
|
||||
@@ -22,3 +22,11 @@ class PaymentLinkCreateResponse(BaseModel):
|
||||
payment_id: str | None = None
|
||||
payment_url: str | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class PaymentLinkStatusResponse(BaseModel):
|
||||
success: bool
|
||||
payment_id: str
|
||||
status: str | None = None
|
||||
completed: bool = False
|
||||
paid: bool = False
|
||||
|
||||
@@ -17,4 +17,7 @@ class TariffPublic(BaseModel):
|
||||
device_limit: int | None
|
||||
subgroup_title: str | None
|
||||
sort_order: int | None
|
||||
vless: bool = False
|
||||
vless: bool = False
|
||||
configurable: bool = False
|
||||
device_options: list[int] | None = None
|
||||
traffic_options_gb: list[int] | None = None
|
||||
|
||||
+71
-1
@@ -1,6 +1,9 @@
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
_MAX_BLOCK_DATA_SIZE = 256 * 1024
|
||||
|
||||
|
||||
class WebBlockBase(BaseModel):
|
||||
@@ -8,6 +11,12 @@ class WebBlockBase(BaseModel):
|
||||
order: int
|
||||
data: dict[str, Any]
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_data_size(self) -> "WebBlockBase":
|
||||
if len(json.dumps(self.data, ensure_ascii=False)) > _MAX_BLOCK_DATA_SIZE:
|
||||
raise ValueError(f"Размер data блока не должен превышать {_MAX_BLOCK_DATA_SIZE // 1024} КБ")
|
||||
return self
|
||||
|
||||
|
||||
class WebBlockResponse(WebBlockBase):
|
||||
id: str
|
||||
@@ -20,10 +29,19 @@ class WebTheme(BaseModel):
|
||||
tokens: dict[str, Any]
|
||||
|
||||
|
||||
class WebPageVariantSummary(BaseModel):
|
||||
key: str = Field(..., max_length=64)
|
||||
name: str = Field(..., max_length=255)
|
||||
is_active: bool = False
|
||||
|
||||
|
||||
class WebPageResponse(BaseModel):
|
||||
slug: str
|
||||
blocks: list[WebBlockResponse]
|
||||
theme: WebTheme | None = None
|
||||
variant_key: str = "default"
|
||||
active_variant_key: str = "default"
|
||||
variants: list[WebPageVariantSummary] = Field(default_factory=list)
|
||||
|
||||
|
||||
class WebPageUpdate(BaseModel):
|
||||
@@ -31,6 +49,58 @@ class WebPageUpdate(BaseModel):
|
||||
theme: WebTheme | None = None
|
||||
|
||||
|
||||
class WebPageVariantCreate(BaseModel):
|
||||
key: str | None = Field(default=None, max_length=64)
|
||||
name: str | None = Field(default=None, max_length=255)
|
||||
from_variant_key: str | None = Field(default=None, max_length=64)
|
||||
|
||||
|
||||
class WebPageVariantUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, max_length=255)
|
||||
make_active: bool | None = None
|
||||
|
||||
|
||||
class WebPageVariantsResponse(BaseModel):
|
||||
slug: str
|
||||
active_variant_key: str = "default"
|
||||
current_variant_key: str = "default"
|
||||
variants: list[WebPageVariantSummary] = Field(default_factory=list)
|
||||
|
||||
|
||||
class WebUploadResponse(BaseModel):
|
||||
url: str
|
||||
|
||||
|
||||
class FlowStepConfig(BaseModel):
|
||||
provider_ids: list[str] | None = None
|
||||
tariff_group_code: str | None = None
|
||||
tariff_ids: list[int] | None = None
|
||||
display_mode: str | None = None
|
||||
skippable: bool = False
|
||||
auto_advance_if_single: bool = False
|
||||
|
||||
|
||||
class FlowStepSchema(BaseModel):
|
||||
id: str = Field(..., max_length=64)
|
||||
type: str = Field(..., max_length=32)
|
||||
label: str = Field(..., max_length=255)
|
||||
label_en: str | None = Field(default=None, max_length=255)
|
||||
enabled: bool = True
|
||||
page_slug: str | None = Field(default=None, max_length=64)
|
||||
config: FlowStepConfig = Field(default_factory=FlowStepConfig)
|
||||
|
||||
|
||||
class FlowDefinitionSchema(BaseModel):
|
||||
id: str = Field(..., max_length=64)
|
||||
name: str = Field(..., max_length=255)
|
||||
steps: list[FlowStepSchema] = Field(default_factory=list)
|
||||
version: int = 1
|
||||
|
||||
|
||||
class FlowDefinitionResponse(FlowDefinitionSchema):
|
||||
pass
|
||||
|
||||
|
||||
class FlowDefinitionUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, max_length=255)
|
||||
steps: list[FlowStepSchema] = Field(default_factory=list)
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class AccountSummaryResponse(BaseModel):
|
||||
identity_id: str
|
||||
email: str | None = None
|
||||
tg_id: int | None = None
|
||||
linked_telegram: bool = False
|
||||
referral_code: str = ""
|
||||
balance: float = 0.0
|
||||
trial_status: int = 0
|
||||
keys_total: int = 0
|
||||
referrals_total: int = 0
|
||||
referrals_active: int = 0
|
||||
referral_bonus_total: float = 0.0
|
||||
gifts_sent: int = 0
|
||||
gifts_claimed: int = 0
|
||||
coupons_used: int = 0
|
||||
partner_enabled: bool = False
|
||||
partner_code: str = ""
|
||||
partner_balance: float = 0.0
|
||||
partner_percent: float = 0.0
|
||||
partner_percent_custom: bool = False
|
||||
partner_referred_total: int = 0
|
||||
partner_payout_method: str | None = None
|
||||
unread_notifications: int = 0
|
||||
|
||||
|
||||
class AccountKeyActionsAvailability(BaseModel):
|
||||
can_connect_device: bool = False
|
||||
can_connect_router: bool = False
|
||||
can_connect_tv: bool = False
|
||||
can_renew: bool = False
|
||||
can_addons: bool = False
|
||||
can_reset_hwid: bool = False
|
||||
can_qr: bool = False
|
||||
can_delete: bool = False
|
||||
can_change_location: bool = False
|
||||
|
||||
|
||||
class AccountKeyDetailsResponse(BaseModel):
|
||||
client_id: str
|
||||
email: str
|
||||
alias: str | None = None
|
||||
expiry_time: int = 0
|
||||
is_frozen: bool = False
|
||||
tariff_name: str = ""
|
||||
subgroup_title: str = ""
|
||||
traffic_limit_gb: int = 0
|
||||
used_traffic_gb: float | None = None
|
||||
device_limit: int = 0
|
||||
connected_devices: int = 0
|
||||
is_tariff_configurable: bool = False
|
||||
addons_devices_enabled: bool = False
|
||||
addons_traffic_enabled: bool = False
|
||||
|
||||
|
||||
class AccountKeyResponse(BaseModel):
|
||||
email: str
|
||||
alias: str | None = None
|
||||
client_id: str
|
||||
tariff_id: int | None = None
|
||||
server_id: str
|
||||
created_at: int = 0
|
||||
expiry_time: int = 0
|
||||
key: str | None = None
|
||||
remnawave_link: str | None = None
|
||||
is_frozen: bool = False
|
||||
actions: AccountKeyActionsAvailability | None = None
|
||||
|
||||
|
||||
class AccountKeyAliasUpdateRequest(BaseModel):
|
||||
alias: str = Field(..., min_length=1, max_length=10)
|
||||
|
||||
|
||||
class AccountKeyActionResponse(BaseModel):
|
||||
ok: bool = True
|
||||
message: str = ""
|
||||
|
||||
|
||||
class AccountKeyRenewRequest(BaseModel):
|
||||
provider_id: str | None = None
|
||||
success_url: str | None = None
|
||||
failure_url: str | None = None
|
||||
coupon_code: str | None = None
|
||||
|
||||
|
||||
class AccountKeyRenewResponse(AccountKeyActionResponse):
|
||||
client_id: str
|
||||
tariff_id: int
|
||||
charged_rub: int = 0
|
||||
balance_rub: float = 0.0
|
||||
base_price_rub: int = 0
|
||||
discount_rub: int = 0
|
||||
final_price_rub: int = 0
|
||||
applied_coupon_code: str | None = None
|
||||
payment_required: bool = False
|
||||
required_amount_rub: int = 0
|
||||
payment_id: str | None = None
|
||||
payment_url: str | None = None
|
||||
|
||||
|
||||
class AccountKeyResetHwidResponse(AccountKeyActionResponse):
|
||||
total_devices: int = 0
|
||||
reset_devices: int = 0
|
||||
|
||||
|
||||
class AccountKeyQrResponse(AccountKeyActionResponse):
|
||||
link: str = ""
|
||||
image_data_url: str = ""
|
||||
|
||||
|
||||
class AccountKeyLocationOptionResponse(BaseModel):
|
||||
server_name: str
|
||||
|
||||
|
||||
class AccountKeyLocationsResponse(BaseModel):
|
||||
client_id: str
|
||||
current_server: str = ""
|
||||
locations: list[AccountKeyLocationOptionResponse] = []
|
||||
|
||||
|
||||
class AccountKeyChangeLocationRequest(BaseModel):
|
||||
server_name: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class AccountKeyChangeLocationResponse(AccountKeyActionResponse):
|
||||
client_id: str
|
||||
server_id: str = ""
|
||||
link: str = ""
|
||||
remnawave_link: str | None = None
|
||||
|
||||
|
||||
class AccountKeyAddonOptionResponse(BaseModel):
|
||||
value: int
|
||||
label: str = ""
|
||||
|
||||
|
||||
class AccountKeyAddonsPreviewRequest(BaseModel):
|
||||
selected_device_limit: int | None = None
|
||||
selected_traffic_gb: int | None = None
|
||||
include_device: bool | None = None
|
||||
include_traffic: bool | None = None
|
||||
provider_id: str | None = None
|
||||
success_url: str | None = None
|
||||
failure_url: str | None = None
|
||||
coupon_code: str | None = None
|
||||
|
||||
|
||||
class AccountKeyAddonsPreviewResponse(BaseModel):
|
||||
client_id: str
|
||||
tariff_id: int
|
||||
addons_mode: str = ""
|
||||
has_device_option: bool = False
|
||||
has_traffic_option: bool = False
|
||||
current_device_limit: int | None = None
|
||||
current_traffic_gb: int | None = None
|
||||
selected_device_limit: int | None = None
|
||||
selected_traffic_gb: int | None = None
|
||||
device_options: list[AccountKeyAddonOptionResponse] = []
|
||||
traffic_options: list[AccountKeyAddonOptionResponse] = []
|
||||
total_price_rub: int = 0
|
||||
extra_price_rub: int = 0
|
||||
discount_rub: int = 0
|
||||
final_price_rub: int = 0
|
||||
applied_coupon_code: str | None = None
|
||||
balance_rub: float = 0.0
|
||||
|
||||
|
||||
class AccountKeyApplyAddonsResponse(AccountKeyActionResponse):
|
||||
client_id: str
|
||||
tariff_id: int
|
||||
total_price_rub: int = 0
|
||||
extra_price_rub: int = 0
|
||||
discount_rub: int = 0
|
||||
final_price_rub: int = 0
|
||||
applied_coupon_code: str | None = None
|
||||
charged_rub: int = 0
|
||||
balance_rub: float = 0.0
|
||||
payment_required: bool = False
|
||||
required_amount_rub: int = 0
|
||||
payment_id: str | None = None
|
||||
payment_url: str | None = None
|
||||
|
||||
|
||||
class AccountKeyActionsConfigResponse(BaseModel):
|
||||
renew_enabled: bool = True
|
||||
delete_enabled: bool = False
|
||||
qr_enabled: bool = False
|
||||
hwid_reset_enabled: bool = False
|
||||
country_change_enabled: bool = False
|
||||
instructions_enabled: bool = False
|
||||
addons_enabled: bool = False
|
||||
addons_mode: str = ""
|
||||
tv_connect_enabled: bool = False
|
||||
|
||||
|
||||
class TariffConfigPriceResponse(BaseModel):
|
||||
price_rub: int
|
||||
|
||||
|
||||
class TariffPurchaseRequest(BaseModel):
|
||||
tariff_id: int = Field(..., ge=1)
|
||||
selected_device_limit: int | None = None
|
||||
selected_traffic_gb: int | None = None
|
||||
provider_id: str | None = None
|
||||
success_url: str | None = None
|
||||
failure_url: str | None = None
|
||||
coupon_code: str | None = None
|
||||
|
||||
|
||||
class TariffPurchaseResponse(BaseModel):
|
||||
ok: bool = True
|
||||
message: str = ""
|
||||
key_email: str | None = None
|
||||
charged_rub: int | None = None
|
||||
base_price_rub: int = 0
|
||||
discount_rub: int = 0
|
||||
final_price_rub: int = 0
|
||||
applied_coupon_code: str | None = None
|
||||
payment_required: bool = False
|
||||
required_amount_rub: int = 0
|
||||
payment_id: str | None = None
|
||||
payment_url: str | None = None
|
||||
|
||||
|
||||
class GiftCreateRequest(BaseModel):
|
||||
tariff_id: int = Field(..., ge=1)
|
||||
selected_device_limit: int | None = None
|
||||
selected_traffic_gb: int | None = None
|
||||
provider_id: str | None = None
|
||||
success_url: str | None = None
|
||||
failure_url: str | None = None
|
||||
|
||||
|
||||
class GiftCreatePreviewResponse(BaseModel):
|
||||
ok: bool = True
|
||||
price_rub: int = 0
|
||||
balance_rub: float = 0.0
|
||||
sufficient_funds: bool = True
|
||||
tariff_name: str = ""
|
||||
duration_days: int = 0
|
||||
|
||||
|
||||
class GiftCreateResponse(BaseModel):
|
||||
ok: bool = True
|
||||
message: str = ""
|
||||
gift_id: str = ""
|
||||
site_gift_link: str = ""
|
||||
tariff_name: str = ""
|
||||
duration_days: int = 0
|
||||
price_charged: int = 0
|
||||
balance_rub: float = 0.0
|
||||
payment_required: bool = False
|
||||
required_amount_rub: int = 0
|
||||
payment_id: str | None = None
|
||||
payment_url: str | None = None
|
||||
|
||||
|
||||
class GiftUsageEntry(BaseModel):
|
||||
user_id: int
|
||||
used_at: str | None = None
|
||||
|
||||
|
||||
class MyGiftItem(BaseModel):
|
||||
gift_id: str
|
||||
tariff_name: str = ""
|
||||
duration_days: int = 0
|
||||
price_rub: int = 0
|
||||
created_at: str | None = None
|
||||
expiry_time: str | None = None
|
||||
is_used: bool = False
|
||||
is_unlimited: bool = False
|
||||
max_usages: int | None = None
|
||||
site_gift_link: str = ""
|
||||
usages: list[GiftUsageEntry] = []
|
||||
|
||||
|
||||
class MyGiftsResponse(BaseModel):
|
||||
ok: bool = True
|
||||
gifts: list[MyGiftItem] = []
|
||||
total: int = 0
|
||||
limit: int = 20
|
||||
offset: int = 0
|
||||
|
||||
|
||||
class GiftRedeemRequest(BaseModel):
|
||||
gift_code: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class GiftRedeemResponse(BaseModel):
|
||||
ok: bool = True
|
||||
message: str = ""
|
||||
gift_id: str = ""
|
||||
tariff_id: int = 0
|
||||
duration_days: int = 0
|
||||
|
||||
|
||||
class ReferralApplyRequest(BaseModel):
|
||||
referrer_code: str | None = Field(None, min_length=1)
|
||||
referrer_tg_id: int | None = Field(None, ge=1)
|
||||
|
||||
|
||||
class ReferralApplyResponse(BaseModel):
|
||||
ok: bool = True
|
||||
message: str = ""
|
||||
referrer_code: str = ""
|
||||
referrer_user_id: int = 0
|
||||
referrer_tg_id: int | None = None
|
||||
referred_user_id: int = 0
|
||||
referred_tg_id: int | None = None
|
||||
|
||||
|
||||
class ReferralTopEntryResponse(BaseModel):
|
||||
position: int
|
||||
referrer_user_id: int
|
||||
referrals_count: int
|
||||
display_id: str
|
||||
|
||||
|
||||
class ReferralTopResponse(BaseModel):
|
||||
user_referrals_count: int = 0
|
||||
user_position: int | None = None
|
||||
top: list[ReferralTopEntryResponse] = []
|
||||
|
||||
|
||||
class ReferralQrResponse(BaseModel):
|
||||
ok: bool = True
|
||||
link: str = ""
|
||||
image_data_url: str = ""
|
||||
|
||||
|
||||
class ReferralConditionsResponse(BaseModel):
|
||||
title: str = ""
|
||||
summary: str = ""
|
||||
bonus_mode: str = ""
|
||||
bonus_mode_label: str = ""
|
||||
level_lines: list[str] = []
|
||||
rules: list[str] = []
|
||||
|
||||
|
||||
class PartnerConditionsResponse(BaseModel):
|
||||
title: str = ""
|
||||
summary: str = ""
|
||||
bonus_mode: str = ""
|
||||
bonus_mode_label: str = ""
|
||||
level_lines: list[str] = []
|
||||
rules: list[str] = []
|
||||
examples: list[str] = []
|
||||
min_payout_rub: float = 0.0
|
||||
payout_methods: list[str] = []
|
||||
custom_amount_enabled: bool = False
|
||||
|
||||
|
||||
class PartnerQrResponse(BaseModel):
|
||||
ok: bool = True
|
||||
link: str = ""
|
||||
image_data_url: str = ""
|
||||
|
||||
|
||||
class PartnerApplyRequest(BaseModel):
|
||||
partner_code: str | None = Field(None, min_length=1)
|
||||
partner_tg_id: int | None = Field(None, ge=1)
|
||||
|
||||
|
||||
class PartnerApplyResponse(BaseModel):
|
||||
ok: bool = True
|
||||
message: str = ""
|
||||
partner_code: str = ""
|
||||
partner_user_id: int = 0
|
||||
partner_tg_id: int | None = None
|
||||
joined_user_id: int = 0
|
||||
joined_tg_id: int | None = None
|
||||
|
||||
|
||||
class PartnerTopEntryResponse(BaseModel):
|
||||
position: int
|
||||
partner_user_id: int
|
||||
referred_count: int
|
||||
display_id: str
|
||||
|
||||
|
||||
class PartnerTopResponse(BaseModel):
|
||||
user_referred_count: int = 0
|
||||
user_position: int | None = None
|
||||
top: list[PartnerTopEntryResponse] = []
|
||||
|
||||
|
||||
class CouponApplyRequest(BaseModel):
|
||||
code: str = Field(..., min_length=1, max_length=128)
|
||||
|
||||
|
||||
class CouponApplyResponse(BaseModel):
|
||||
ok: bool = True
|
||||
message: str = ""
|
||||
coupon_code: str = ""
|
||||
amount: int = 0
|
||||
balance: float = 0.0
|
||||
|
||||
|
||||
class PartnerPayoutRequestCreate(BaseModel):
|
||||
amount_rub: float = Field(..., gt=0)
|
||||
|
||||
|
||||
class PartnerPayoutRequestResponse(BaseModel):
|
||||
ok: bool = True
|
||||
message: str = ""
|
||||
request_id: int | None = None
|
||||
amount_rub: float = 0.0
|
||||
status: str = "pending"
|
||||
balance_rub: float = 0.0
|
||||
|
||||
|
||||
class PartnerPayoutEntryResponse(BaseModel):
|
||||
id: int
|
||||
amount_rub: float = 0.0
|
||||
status: str = ""
|
||||
created_at: str | None = None
|
||||
method: str | None = None
|
||||
destination: str | None = None
|
||||
|
||||
|
||||
class PartnerPayoutHistoryResponse(BaseModel):
|
||||
total: int = 0
|
||||
items: list[PartnerPayoutEntryResponse] = []
|
||||
Reference in New Issue
Block a user