diff --git a/.gitignore b/.gitignore
index 3b25f6e9..33d6982c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -27,15 +27,19 @@ env/
*.db
# Sensitive configuration files
-config.py
-config.ini
-alembic.ini
-.env
+/config.py
+/config.ini
+/alembic.ini
+/.env
# Backup files
*.bak
*.swp
*~
+backups/
+backup_bot/
+Solo_backup/
+.cursor/
# Specific project files
vpn_users.db
@@ -51,15 +55,23 @@ handlers/texts.py
Thumbs.db
nginx.conf
-scripts
+/scripts/load_balancer.py
+/scripts/__pycache__
.csv
/logs
-setup.py
.ruff_cache
-.github/workflows/
modules/
storage/
static/web_uploads/
-/web-app/
-.license_state
\ No newline at end of file
+.license_state
+Solo_backup/
+
+.cursor/hooks.json
+.cursor/hooks/after-agent-response.cjs
+.cursor/hooks/before-submit-prompt.cjs
+.cursor/hooks/after-agent-response.js
+.cursor/hooks/before-submit-prompt.js
+.cursor/cursor-notifier.json
+.cursor/cursor-notifier-start.json
+nuitka-crash-report*
diff --git a/Makefile b/Makefile
index 16aac4e6..ffb0eaaf 100644
--- a/Makefile
+++ b/Makefile
@@ -8,3 +8,12 @@ lint:
format-payments:
@echo "Running Ruff format ONLY on handlers/payments..." && ruff format handlers/payments --config pyproject.toml
@echo "Running Ruff check ONLY on handlers/payments..." && ruff check handlers/payments --config pyproject.toml --fix
+
+test:
+ @echo "Running unit tests..." && cd /tmp && PYTHONPATH="$(CURDIR)" "$(CURDIR)/venv/bin/python" -m unittest discover -s "$(CURDIR)/tests" -q
+
+test-sudo:
+ @echo "Running unit tests with sudo..." && cd /tmp && sudo env PYTHONPATH="$(CURDIR)" "$(CURDIR)/venv/bin/python" -m unittest discover -s "$(CURDIR)/tests" -q
+
+smoke:
+ @echo "Running smoke checks..." && bash "$(CURDIR)/tests/smoke_runner.sh"
diff --git a/api/depends.py b/api/depends.py
index 9764683e..fec6baa6 100644
--- a/api/depends.py
+++ b/api/depends.py
@@ -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
diff --git a/api/main.py b/api/main.py
index f6de29a0..502b1dc3 100644
--- a/api/main.py
+++ b/api/main.py
@@ -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"
diff --git a/api/v1/routes/base_crud.py b/api/v1/routes/base_crud.py
index 0d9a6d73..57534871 100644
--- a/api/v1/routes/base_crud.py
+++ b/api/v1/routes/base_crud.py
@@ -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:
diff --git a/api/v1/routes/gifts.py b/api/v1/routes/gifts.py
index 5a6aee73..50f17a72 100644
--- a/api/v1/routes/gifts.py
+++ b/api/v1/routes/gifts.py
@@ -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")
diff --git a/api/v1/routes/keys.py b/api/v1/routes/keys.py
index befe0160..509486ea 100644
--- a/api/v1/routes/keys.py
+++ b/api/v1/routes/keys.py
@@ -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
diff --git a/api/v1/routes/management.py b/api/v1/routes/management.py
index 8b7db907..424389e0 100644
--- a/api/v1/routes/management.py
+++ b/api/v1/routes/management.py
@@ -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)
)
diff --git a/api/v1/routes/misc.py b/api/v1/routes/misc.py
index 7ee133b7..924746f5 100644
--- a/api/v1/routes/misc.py
+++ b/api/v1/routes/misc.py
@@ -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",
diff --git a/api/v1/routes/referrals.py b/api/v1/routes/referrals.py
index 4dac1115..f420ca9a 100644
--- a/api/v1/routes/referrals.py
+++ b/api/v1/routes/referrals.py
@@ -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:
diff --git a/api/v1/routes/users.py b/api/v1/routes/users.py
index 9bdb23b7..63969db5 100644
--- a/api/v1/routes/users.py
+++ b/api/v1/routes/users.py
@@ -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:
diff --git a/api/v1/schemas/gifts.py b/api/v1/schemas/gifts.py
index b6260c2b..b503d759 100644
--- a/api/v1/schemas/gifts.py
+++ b/api/v1/schemas/gifts.py
@@ -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
diff --git a/api/v1/schemas/keys.py b/api/v1/schemas/keys.py
index caae97bb..5c69d0d7 100644
--- a/api/v1/schemas/keys.py
+++ b/api/v1/schemas/keys.py
@@ -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
diff --git a/api/v1/schemas/misc.py b/api/v1/schemas/misc.py
index 467fd364..caef8334 100644
--- a/api/v1/schemas/misc.py
+++ b/api/v1/schemas/misc.py
@@ -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
diff --git a/api/v1/schemas/referrals.py b/api/v1/schemas/referrals.py
index 39c60d42..dab27b20 100644
--- a/api/v1/schemas/referrals.py
+++ b/api/v1/schemas/referrals.py
@@ -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:
diff --git a/api/v2/__init__.py b/api/v2/__init__.py
index 0e439d72..2cfb418c 100644
--- a/api/v2/__init__.py
+++ b/api/v2/__init__.py
@@ -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)
diff --git a/api/v2/base_crud.py b/api/v2/base_crud.py
index c98cda8d..e12d3ac5 100644
--- a/api/v2/base_crud.py
+++ b/api/v2/base_crud.py
@@ -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:
diff --git a/api/v2/router.py b/api/v2/router.py
index 2134506e..ad4932c0 100644
--- a/api/v2/router.py
+++ b/api/v2/router.py
@@ -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"])
\ No newline at end of file
+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"])
diff --git a/api/v2/routes/__init__.py b/api/v2/routes/__init__.py
index 31b027c0..e7bad74b 100644
--- a/api/v2/routes/__init__.py
+++ b/api/v2/routes/__init__.py
@@ -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)
diff --git a/api/v2/routes/auth.py b/api/v2/routes/auth.py
deleted file mode 100644
index c9105120..00000000
--- a/api/v2/routes/auth.py
+++ /dev/null
@@ -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)
diff --git a/api/v2/routes/auth/__init__.py b/api/v2/routes/auth/__init__.py
new file mode 100644
index 00000000..4886dcde
--- /dev/null
+++ b/api/v2/routes/auth/__init__.py
@@ -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"]
diff --git a/api/v2/routes/auth/_common.py b/api/v2/routes/auth/_common.py
new file mode 100644
index 00000000..9ba06c8e
--- /dev/null
+++ b/api/v2/routes/auth/_common.py
@@ -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
diff --git a/api/v2/routes/auth/_fallback_limiter.py b/api/v2/routes/auth/_fallback_limiter.py
new file mode 100644
index 00000000..f19e8c53
--- /dev/null
+++ b/api/v2/routes/auth/_fallback_limiter.py
@@ -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)
diff --git a/api/v2/routes/auth/email_verify.py b/api/v2/routes/auth/email_verify.py
new file mode 100644
index 00000000..26c22c8c
--- /dev/null
+++ b/api/v2/routes/auth/email_verify.py
@@ -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}
diff --git a/api/v2/routes/auth/link.py b/api/v2/routes/auth/link.py
new file mode 100644
index 00000000..00a87e72
--- /dev/null
+++ b/api/v2/routes/auth/link.py
@@ -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)
diff --git a/api/v2/routes/auth/password.py b/api/v2/routes/auth/password.py
new file mode 100644
index 00000000..da096e36
--- /dev/null
+++ b/api/v2/routes/auth/password.py
@@ -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)
diff --git a/api/v2/routes/auth/session.py b/api/v2/routes/auth/session.py
new file mode 100644
index 00000000..34ecc478
--- /dev/null
+++ b/api/v2/routes/auth/session.py
@@ -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}
diff --git a/api/v2/routes/auth/telegram.py b/api/v2/routes/auth/telegram.py
new file mode 100644
index 00000000..0e7a32a6
--- /dev/null
+++ b/api/v2/routes/auth/telegram.py
@@ -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)
diff --git a/api/v2/routes/coupon_pricing.py b/api/v2/routes/coupon_pricing.py
new file mode 100644
index 00000000..901d4798
--- /dev/null
+++ b/api/v2/routes/coupon_pricing.py
@@ -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,
+ )
diff --git a/api/v2/routes/coupons.py b/api/v2/routes/coupons.py
index 2cf50d78..cf8cde4c 100644
--- a/api/v2/routes/coupons.py
+++ b/api/v2/routes/coupons.py
@@ -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="Ошибка активации купона")
diff --git a/api/v2/routes/flows.py b/api/v2/routes/flows.py
new file mode 100644
index 00000000..a9671f16
--- /dev/null
+++ b/api/v2/routes/flows.py
@@ -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()
diff --git a/api/v2/routes/gifts.py b/api/v2/routes/gifts.py
index 9ef431d3..718ec457 100644
--- a/api/v2/routes/gifts.py
+++ b/api/v2/routes/gifts.py
@@ -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")
diff --git a/api/v2/routes/keys/__init__.py b/api/v2/routes/keys/__init__.py
new file mode 100644
index 00000000..a68ebb7e
--- /dev/null
+++ b/api/v2/routes/keys/__init__.py
@@ -0,0 +1,4 @@
+from ._common import router, user_router
+from . import admin, user # noqa: F401 — import triggers endpoint registration
+
+__all__ = ["router", "user_router"]
diff --git a/api/v2/routes/keys/_common.py b/api/v2/routes/keys/_common.py
new file mode 100644
index 00000000..dcc04ba4
--- /dev/null
+++ b/api/v2/routes/keys/_common.py
@@ -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
+
+
diff --git a/api/v2/routes/keys.py b/api/v2/routes/keys/admin.py
similarity index 84%
rename from api/v2/routes/keys.py
rename to api/v2/routes/keys/admin.py
index 9ab70733..06df1847 100644
--- a/api/v2/routes/keys.py
+++ b/api/v2/routes/keys/admin.py
@@ -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="Ошибка при создании ключа")
+
diff --git a/api/v2/routes/keys/user/__init__.py b/api/v2/routes/keys/user/__init__.py
new file mode 100644
index 00000000..e834a77a
--- /dev/null
+++ b/api/v2/routes/keys/user/__init__.py
@@ -0,0 +1 @@
+from . import addons, core, hwid, location, renew # noqa: F401 — trigger registration
diff --git a/api/v2/routes/keys/user/addons.py b/api/v2/routes/keys/user/addons.py
new file mode 100644
index 00000000..288b254c
--- /dev/null
+++ b/api/v2/routes/keys/user/addons.py
@@ -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))),
+ )
diff --git a/api/v2/routes/keys/user/core.py b/api/v2/routes/keys/user/core.py
new file mode 100644
index 00000000..3aaff74e
--- /dev/null
+++ b/api/v2/routes/keys/user/core.py
@@ -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="Подписка удалена")
diff --git a/api/v2/routes/keys/user/hwid.py b/api/v2/routes/keys/user/hwid.py
new file mode 100644
index 00000000..574414c3
--- /dev/null
+++ b/api/v2/routes/keys/user/hwid.py
@@ -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),
+ )
diff --git a/api/v2/routes/keys/user/location.py b/api/v2/routes/keys/user/location.py
new file mode 100644
index 00000000..5af17b50
--- /dev/null
+++ b/api/v2/routes/keys/user/location.py
@@ -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),
+ )
diff --git a/api/v2/routes/keys/user/renew.py b/api/v2/routes/keys/user/renew.py
new file mode 100644
index 00000000..87d39a6f
--- /dev/null
+++ b/api/v2/routes/keys/user/renew.py
@@ -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,
+ )
diff --git a/api/v2/routes/management.py b/api/v2/routes/management.py
index 91da90d5..44289062 100644
--- a/api/v2/routes/management.py
+++ b/api/v2/routes/management.py
@@ -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:
diff --git a/api/v2/routes/misc.py b/api/v2/routes/misc.py
index b97d2d50..f6a9b29b 100644
--- a/api/v2/routes/misc.py
+++ b/api/v2/routes/misc.py
@@ -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",
diff --git a/api/v2/routes/modules.py b/api/v2/routes/modules.py
index 5f24035f..0248380e 100644
--- a/api/v2/routes/modules.py
+++ b/api/v2/routes/modules.py
@@ -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)}
diff --git a/api/v2/routes/notifications.py b/api/v2/routes/notifications.py
new file mode 100644
index 00000000..d23ff1cc
--- /dev/null
+++ b/api/v2/routes/notifications.py
@@ -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}
diff --git a/api/v2/routes/partners.py b/api/v2/routes/partners.py
index 17dedd47..85ccf04f 100644
--- a/api/v2/routes/partners.py
+++ b/api/v2/routes/partners.py
@@ -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="Лимит результатов"),
diff --git a/api/v2/routes/payment_links.py b/api/v2/routes/payment_links.py
index be630383..8c10d32b 100644
--- a/api/v2/routes/payment_links.py
+++ b/api/v2/routes/payment_links.py
@@ -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",
+ )
diff --git a/api/v2/routes/referrals.py b/api/v2/routes/referrals.py
index db3764a8..647d82bd 100644
--- a/api/v2/routes/referrals.py
+++ b/api/v2/routes/referrals.py
@@ -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"}
diff --git a/api/v2/routes/root.py b/api/v2/routes/root.py
index 43feae24..64ee8d0d 100644
--- a/api/v2/routes/root.py
+++ b/api/v2/routes/root.py
@@ -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,
+ },
+ }
diff --git a/api/v2/routes/tariffs.py b/api/v2/routes/tariffs.py
index b1b69783..36fad4c6 100644
--- a/api/v2/routes/tariffs.py
+++ b/api/v2/routes/tariffs.py
@@ -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(
diff --git a/api/v2/routes/users.py b/api/v2/routes/users.py
index 0f334090..9fd37334 100644
--- a/api/v2/routes/users.py
+++ b/api/v2/routes/users.py
@@ -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:
diff --git a/api/v2/routes/web.py b/api/v2/routes/web.py
index 75b2f0f3..1a8b1b58 100644
--- a/api/v2/routes/web.py
+++ b/api/v2/routes/web.py
@@ -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"", "", text, flags=_re.DOTALL | _re.IGNORECASE)
+ text = _re.sub(r"", "", 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"]*>.*?", "", text, flags=_re.DOTALL | _re.IGNORECASE)
+ text = _re.sub(r"", "", text, flags=_re.DOTALL | _re.IGNORECASE)
+ text = _re.sub(r"