WEB-APP/ Optimization/ Build fix/ Hotkey edit mode/ Log rotation/ Form a11y/ E2E non-blocking

This commit is contained in:
Vladless
2026-04-13 00:38:29 +00:00
parent c1b9b20ea3
commit c693c28ee7
270 changed files with 20933 additions and 6365 deletions
+21 -9
View File
@@ -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
.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*
+9
View File
@@ -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"
+154 -21
View File
@@ -1,14 +1,16 @@
import hashlib
from urllib.parse import urlparse
from collections.abc import AsyncGenerator
from fastapi import Depends, HTTPException, Header, Query, Request
from fastapi import Depends, HTTPException, Header, Query, Request, Response
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from audit import set_api_actor
from database import async_session_maker, identities as idb
from database.models import Admin
from database.access.resolution import ResolvedActor, resolve_actor_from_identity
from database.models import Admin, Identity
async def get_session() -> AsyncGenerator[AsyncSession, None]:
@@ -25,6 +27,24 @@ def hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
async def bind_identity_actor(
request: Request | None,
session: AsyncSession,
identity: Identity,
) -> ResolvedActor:
actor = await resolve_actor_from_identity(session, identity)
set_api_actor(request, identity_id=actor.identity_id, tg_id=actor.telegram_chat_id)
if request is not None:
request.state.actor = actor
return actor
def get_request_actor(request: Request | None) -> ResolvedActor | None:
if request is None:
return None
return getattr(request.state, "actor", None)
async def verify_admin_token(
admin_id: int = Query(..., alias="tg_id"),
token: str = Header(..., alias="X-Token"),
@@ -40,50 +60,146 @@ async def verify_admin_token(
return admin
AUTH_COOKIE_NAME = "auth_token"
IS_ADMIN_COOKIE_NAME = "is_admin"
AUTH_COOKIE_MAX_AGE_SECONDS = 30 * 24 * 60 * 60
def _is_secure_request(request: Request | None) -> bool:
if request is None:
return False
if request.url.scheme == "https":
return True
forwarded_proto = request.headers.get("x-forwarded-proto", "").lower()
return forwarded_proto == "https"
def set_auth_cookie(response: Response, token: str, request: Request | None = None) -> None:
"""Устанавливает HttpOnly cookie с auth-токеном на ответ. Используется во всех login-ручках."""
response.set_cookie(
key=AUTH_COOKIE_NAME,
value=token,
max_age=AUTH_COOKIE_MAX_AGE_SECONDS,
path="/",
httponly=True,
secure=_is_secure_request(request),
samesite="lax",
)
def clear_auth_cookie(response: Response, request: Request | None = None) -> None:
"""Удаляет auth cookie на стороне браузера."""
response.delete_cookie(
key=AUTH_COOKIE_NAME,
path="/",
httponly=True,
secure=_is_secure_request(request),
samesite="lax",
)
clear_is_admin_cookie(response, request)
def set_is_admin_cookie(response: Response, identity: Identity, request: Request | None = None) -> None:
"""Ставит/гасит `is_admin` cookie в зависимости от текущей identity."""
if getattr(identity, "is_admin", False):
response.set_cookie(
key=IS_ADMIN_COOKIE_NAME,
value="1",
max_age=AUTH_COOKIE_MAX_AGE_SECONDS,
path="/",
httponly=True,
secure=_is_secure_request(request),
samesite="lax",
)
else:
clear_is_admin_cookie(response, request)
def clear_is_admin_cookie(response: Response, request: Request | None = None) -> None:
response.delete_cookie(
key=IS_ADMIN_COOKIE_NAME,
path="/",
httponly=True,
secure=_is_secure_request(request),
samesite="lax",
)
def _read_auth_cookie(request: Request | None) -> str | None:
if request is None:
return None
raw = request.cookies.get(AUTH_COOKIE_NAME)
if not raw:
return None
raw = raw.strip()
return raw or None
async def _identity_from_cookie(session: AsyncSession, request: Request | None) -> Identity | None:
token = _read_auth_cookie(request)
if not token:
return None
token_hash = hash_token(token)
identity = await idb.get_identity_by_token_hash(session, token_hash)
if identity is None:
return None
if idb._is_token_expired(identity):
return None
return identity
async def verify_identity_token(
x_identity_id: str = Header(..., alias="X-Identity-Id"),
token: str = Header(..., alias="X-Token"),
request: Request = None,
request: Request,
session: AsyncSession = Depends(get_session),
):
"""Проверяет пару identity_id + token; возвращает Identity. Для использования в API v2."""
identity = await idb.verify_identity_token(session, x_identity_id, token)
if not identity:
"""Проверяет токен из HttpOnly cookie `auth_token`; возвращает Identity."""
identity = await _identity_from_cookie(session, request)
if identity is None:
raise HTTPException(status_code=401, detail="Unauthorized")
set_api_actor(request, identity_id=identity.id, tg_id=identity.tg_id)
await bind_identity_actor(request, session, identity)
return identity
async def verify_identity_admin(
x_identity_id: str = Header(..., alias="X-Identity-Id"),
token: str = Header(..., alias="X-Token"),
request: Request = None,
request: Request,
session: AsyncSession = Depends(get_session),
):
"""Проверяет identity + token и что identity.is_admin; для админских ручек v2."""
identity = await idb.verify_identity_token(session, x_identity_id, token)
if not identity:
"""Проверяет токен из cookie и что identity.is_admin; для админских ручек v2."""
identity = await _identity_from_cookie(session, request)
if identity is None:
raise HTTPException(status_code=401, detail="Unauthorized")
if not identity.is_admin:
raise HTTPException(status_code=403, detail="Forbidden")
set_api_actor(request, identity_id=identity.id, tg_id=identity.tg_id)
await bind_identity_actor(request, session, identity)
return identity
async def verify_identity_admin_short(
x_identity_id: str = Header(..., alias="X-Identity-Id"),
token: str = Header(..., alias="X-Token"),
request: Request = None,
request: Request,
):
"""Проверка админа с короткой сессией (для broadcast и др.), чтобы не держать соединение с БД."""
identity = None
actor = None
async with async_session_maker() as session:
identity = await idb.verify_identity_token(session, x_identity_id, token)
identity = await _identity_from_cookie(session, request)
if identity:
actor = await resolve_actor_from_identity(session, identity)
await session.commit()
if not identity:
raise HTTPException(status_code=401, detail="Unauthorized")
if not identity.is_admin:
raise HTTPException(status_code=403, detail="Forbidden")
set_api_actor(request, identity_id=identity.id, tg_id=identity.tg_id)
if actor is not None:
set_api_actor(request, identity_id=identity.id, tg_id=actor.telegram_chat_id)
if request is not None:
request.state.actor = actor
else:
set_api_actor(request, identity_id=identity.id, tg_id=identity.tg_id)
return identity
@@ -102,3 +218,20 @@ async def verify_admin_token_short(
raise HTTPException(status_code=401, detail="Unauthorized")
set_api_actor(request, tg_id=admin.tg_id)
return admin
def validate_redirect_url(url: str, base_url: str) -> str:
"""Validate redirect URL is same-origin or relative. Returns safe URL or base_url fallback."""
url = url.strip()
if not url:
return base_url
if url.startswith("/"):
return url
try:
parsed = urlparse(url)
base_parsed = urlparse(base_url)
if parsed.scheme in ("http", "https") and parsed.netloc == base_parsed.netloc:
return url
except Exception:
pass
return base_url
+24 -5
View File
@@ -14,7 +14,8 @@ from logger import logger
if API_VERSION == 1:
from api.v1 import router as api_router, VERSION as API_DOC_VERSION
else:
from api.v2 import router as api_router, VERSION as API_DOC_VERSION
from api.v2 import VERSION as API_DOC_VERSION
from api.v2.router import router as api_router
app = FastAPI(
title=f"SoloBot API (Alpha) — API v{API_DOC_VERSION}",
@@ -25,15 +26,28 @@ app = FastAPI(
openapi_url="/api/openapi.json",
)
_cors_origins = API_CORS_ORIGINS if API_CORS_ORIGINS != ["*"] else API_CORS_ORIGINS
_cors_credentials = API_CORS_ORIGINS != ["*"]
app.add_middleware(
CORSMiddleware,
allow_origins=API_CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
allow_origins=_cors_origins,
allow_credentials=_cors_credentials,
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
allow_headers=["X-Identity-Id", "X-Token", "Content-Type", "Authorization"],
)
@app.middleware("http")
async def security_headers_middleware(request: Request, call_next):
response = await call_next(request)
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
response.headers.setdefault("X-XSS-Protection", "1; mode=block")
return response
@app.middleware("http")
async def api_access_log_middleware(request: Request, call_next):
context = ensure_api_context(request)
@@ -86,6 +100,11 @@ async def api_access_log_middleware(request: Request, call_next):
return response
@app.get("/api/health", include_in_schema=False)
async def health():
return {"status": "ok"}
app.include_router(api_router)
_web_uploads_dir = "static/web_uploads"
+58 -11
View File
@@ -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:
+5 -1
View File
@@ -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")
+6 -2
View File
@@ -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
+1 -1
View File
@@ -208,7 +208,7 @@ async def restore_trials(
update(User)
.where(
User.trial == 1,
~exists(select(Key.tg_id).where(Key.tg_id == User.tg_id)),
~exists(select(Key.user_id).where(Key.user_id == User.id)),
)
.values(trial=0)
)
+16 -5
View File
@@ -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",
+12 -2
View File
@@ -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:
+6 -2
View File
@@ -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:
+7 -3
View File
@@ -4,11 +4,13 @@ from pydantic import BaseModel
class GiftBase(BaseModel):
sender_tg_id: int
recipient_tg_id: int | None = None
sender_user_id: int
recipient_user_id: int | None = None
selected_months: int | None = None
expiry_time: datetime
gift_link: str
telegram_gift_link: str | None = None
site_gift_link: str | None = None
is_used: bool = False
is_unlimited: bool | None = False
max_usages: int | None = None
@@ -33,10 +35,12 @@ class GiftUsageResponse(BaseModel):
class GiftUpdate(BaseModel):
recipient_tg_id: int | None = None
recipient_user_id: int | None = None
selected_months: int | None = None
expiry_time: datetime | None = None
gift_link: str | None = None
telegram_gift_link: str | None = None
site_gift_link: str | None = None
is_used: bool | None = None
is_unlimited: bool | None = None
max_usages: int | None = None
+2 -1
View File
@@ -2,8 +2,9 @@ from pydantic import BaseModel, Field
class KeyBase(BaseModel):
tg_id: int
user_id: int
client_id: str
tg_id: int | None = None
email: str | None = None
created_at: int | None = None
expiry_time: int
+14 -8
View File
@@ -4,7 +4,8 @@ from pydantic import BaseModel
class PaymentBase(BaseModel):
tg_id: int
user_id: int
tg_id: int | None = None
amount: float
payment_system: str
status: str
@@ -19,8 +20,8 @@ class PaymentResponse(PaymentBase):
class ReferralResponse(BaseModel):
referred_tg_id: int
referrer_tg_id: int
referred_user_id: int
referrer_user_id: int
reward_issued: bool = False
class Config:
@@ -37,11 +38,13 @@ class NotificationResponse(BaseModel):
class GiftBase(BaseModel):
sender_tg_id: int
recipient_tg_id: int | None = None
sender_user_id: int
recipient_user_id: int | None = None
selected_months: int
expiry_time: datetime
gift_link: str
telegram_gift_link: str | None = None
site_gift_link: str | None = None
is_used: bool = False
is_unlimited: bool = False
max_usages: int | None = None
@@ -66,7 +69,8 @@ class GiftUsageResponse(BaseModel):
class ManualBanResponse(BaseModel):
tg_id: int
user_id: int
tg_id: int | None = None
banned_at: datetime
reason: str
banned_by: int
@@ -77,7 +81,8 @@ class ManualBanResponse(BaseModel):
class TemporaryDataResponse(BaseModel):
tg_id: int
user_id: int
tg_id: int | None = None
state: str
data: dict
updated_at: datetime
@@ -87,7 +92,8 @@ class TemporaryDataResponse(BaseModel):
class BlockedUserResponse(BaseModel):
tg_id: int
user_id: int
tg_id: int | None = None
class Config:
from_attributes = True
+2 -2
View File
@@ -2,8 +2,8 @@ from pydantic import BaseModel
class ReferralResponse(BaseModel):
referred_tg_id: int
referrer_tg_id: int
referred_user_id: int
referrer_user_id: int
reward_issued: bool = False
class Config:
+7 -3
View File
@@ -1,5 +1,9 @@
from api.v2.router import router
VERSION = "2.0.0"
__all__ = ("router", "VERSION")
def __getattr__(name: str):
if name == "router":
from api.v2.router import router
return router
raise AttributeError(name)
+40 -12
View File
@@ -6,7 +6,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm.attributes import InstrumentedAttribute
from api.depends import get_session, verify_identity_admin
from api.v1.routes.base_crud import cast_identifier_type, normalize_outgoing_object, to_schema
from api.v1.routes.base_crud import (
_apply_user_relationship_loader,
cast_identifier_type,
normalize_outgoing_object,
to_schema,
)
from database.access.resolution import resolve_user_optional
def generate_crud_router(
@@ -18,10 +24,20 @@ def generate_crud_router(
identifier_field: str = "tg_id",
parameter_name: str = "tg_id",
extra_get_by_email: bool = False,
telegram_path_to_user_id: bool = False,
enabled_methods: list[str] = ("get_all", "get_one", "get_by_email", "create", "update", "delete"),
) -> APIRouter:
router = APIRouter()
async def _path_filter(session: AsyncSession, value: int | str):
if telegram_path_to_user_id:
u = await resolve_user_optional(session, int(value))
if u is None:
return None
return getattr(model, "user_id"), u.id
field = getattr(model, identifier_field)
return field, cast_identifier_type(field, value)
if "get_all" in enabled_methods:
@router.get("/", response_model=list[schema_response])
@@ -29,7 +45,7 @@ def generate_crud_router(
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(model))
result = await session.execute(_apply_user_relationship_loader(model, select(model)))
items = result.scalars().all()
for item in items:
normalize_outgoing_object(item)
@@ -57,9 +73,13 @@ def generate_crud_router(
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
field = getattr(model, identifier_field)
casted = cast_identifier_type(field, value)
result = await session.execute(select(model).where(field == casted))
resolved = await _path_filter(session, value)
if resolved is None:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
field, casted = resolved
result = await session.execute(
_apply_user_relationship_loader(model, select(model).where(field == casted))
)
obj = result.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
@@ -73,9 +93,13 @@ def generate_crud_router(
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
field = getattr(model, identifier_field)
casted = cast_identifier_type(field, value)
result = await session.execute(select(model).where(field == casted))
resolved = await _path_filter(session, value)
if resolved is None:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
field, casted = resolved
result = await session.execute(
_apply_user_relationship_loader(model, select(model).where(field == casted))
)
objs = result.scalars().all()
if not objs:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
@@ -110,8 +134,10 @@ def generate_crud_router(
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
field = getattr(model, identifier_field)
casted = cast_identifier_type(field, value)
resolved = await _path_filter(session, value)
if resolved is None:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
field, casted = resolved
result = await session.execute(select(model).where(field == casted))
obj = result.scalar_one_or_none()
if not obj:
@@ -131,8 +157,10 @@ def generate_crud_router(
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
field = getattr(model, identifier_field)
casted = cast_identifier_type(field, value)
resolved = await _path_filter(session, value)
if resolved is None:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
field, casted = resolved
result = await session.execute(select(model).where(field == casted))
obj = result.scalar_one_or_none()
if not obj:
+8 -2
View File
@@ -18,6 +18,8 @@ from api.v2.routes import (
payment_links,
identities,
web,
flows,
notifications,
)
router = APIRouter()
@@ -25,10 +27,12 @@ router = APIRouter()
router.include_router(root_router)
router.include_router(auth.router, prefix="/api")
router.include_router(users.router, prefix="/api/users", tags=["Users"])
router.include_router(keys.router, prefix="/api/keys", tags=["Keys"])
router.include_router(keys.user_router, prefix="/api/keys", tags=["Keys"])
router.include_router(keys.router, prefix="/api/admin/keys", tags=["AdminKeys"])
router.include_router(coupons.router, prefix="/api/coupons", tags=["Coupons"])
router.include_router(servers.router, prefix="/api/servers", tags=["Servers"])
router.include_router(tariffs.public_router, prefix="/api/tariffs", tags=["Tariffs"])
router.include_router(tariffs.user_tariff_router, prefix="/api/tariffs", tags=["Tariffs"])
router.include_router(tariffs.router, prefix="/api/tariffs", tags=["Tariffs"])
router.include_router(gifts.router, prefix="/api/gifts", tags=["Gifts"])
router.include_router(referrals.router, prefix="/api/referrals", tags=["Referrals"])
@@ -39,4 +43,6 @@ router.include_router(misc.router, prefix="/api")
router.include_router(modules.router, prefix="/api")
router.include_router(management.router, prefix="/api/management", tags=["Management"])
router.include_router(settings.router, prefix="/api/settings", tags=["Settings"])
router.include_router(web.router, prefix="", tags=["Web"])
router.include_router(web.router, prefix="", tags=["Web"])
router.include_router(flows.router, prefix="/api", tags=["Flows"])
router.include_router(notifications.router, prefix="/api", tags=["Notifications"])
+8 -2
View File
@@ -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)
-173
View File
@@ -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)
+13
View File
@@ -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"]
+112
View File
@@ -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
+43
View File
@@ -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)
+79
View File
@@ -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}
+114
View File
@@ -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)
+389
View File
@@ -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)
+151
View File
@@ -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}
+101
View File
@@ -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)
+31
View File
@@ -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,
)
+57 -1
View File
@@ -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="Ошибка активации купона")
+115
View File
@@ -0,0 +1,115 @@
from __future__ import annotations
from datetime import datetime, UTC
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_identity_admin
from api.v2.schemas.flows import FlowCreate, FlowResponse, FlowUpdate
from database.models import WebFlow
router = APIRouter()
def _flow_to_response(flow: WebFlow) -> FlowResponse:
return FlowResponse(
id=flow.id,
name=flow.name,
nodes=flow.nodes or [],
edges=flow.edges or [],
entry_node_id=flow.entry_node_id,
version=flow.version,
)
@router.get("/flows/{flow_id}", response_model=FlowResponse)
async def get_flow_public(flow_id: str, session: AsyncSession = Depends(get_session)):
flow = await session.get(WebFlow, flow_id)
if not flow:
raise HTTPException(404, "Flow not found")
return _flow_to_response(flow)
@router.get("/admin/flows", response_model=list[FlowResponse])
async def list_flows(
session: AsyncSession = Depends(get_session),
_identity=Depends(verify_identity_admin),
):
result = await session.execute(select(WebFlow))
return [_flow_to_response(f) for f in result.scalars().all()]
@router.get("/admin/flows/{flow_id}", response_model=FlowResponse)
async def get_flow_admin(
flow_id: str,
session: AsyncSession = Depends(get_session),
_identity=Depends(verify_identity_admin),
):
flow = await session.get(WebFlow, flow_id)
if not flow:
raise HTTPException(404, "Flow not found")
return _flow_to_response(flow)
@router.post("/admin/flows", response_model=FlowResponse, status_code=201)
async def create_flow(
body: FlowCreate,
session: AsyncSession = Depends(get_session),
_identity=Depends(verify_identity_admin),
):
existing = await session.get(WebFlow, body.id)
if existing:
raise HTTPException(409, "Flow with this ID already exists")
flow = WebFlow(
id=body.id,
name=body.name,
nodes=[n.model_dump() for n in body.nodes],
edges=[e.model_dump() for e in body.edges],
entry_node_id=body.entry_node_id,
version=1,
updated_at=datetime.now(UTC),
)
session.add(flow)
await session.commit()
await session.refresh(flow)
return _flow_to_response(flow)
@router.put("/admin/flows/{flow_id}", response_model=FlowResponse)
async def update_flow(
flow_id: str,
body: FlowUpdate,
session: AsyncSession = Depends(get_session),
_identity=Depends(verify_identity_admin),
):
flow = await session.get(WebFlow, flow_id)
if not flow:
raise HTTPException(404, "Flow not found")
if body.name is not None:
flow.name = body.name
flow.nodes = [n.model_dump() for n in body.nodes]
flow.edges = [e.model_dump() for e in body.edges]
flow.entry_node_id = body.entry_node_id
flow.version = flow.version + 1
flow.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(flow)
return _flow_to_response(flow)
@router.delete("/admin/flows/{flow_id}", status_code=204)
async def delete_flow(
flow_id: str,
session: AsyncSession = Depends(get_session),
_identity=Depends(verify_identity_admin),
):
flow = await session.get(WebFlow, flow_id)
if not flow:
raise HTTPException(404, "Flow not found")
await session.delete(flow)
await session.commit()
+274 -5
View File
@@ -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")
+4
View File
@@ -0,0 +1,4 @@
from ._common import router, user_router
from . import admin, user # noqa: F401 — import triggers endpoint registration
__all__ = ["router", "user_router"]
+279
View File
@@ -0,0 +1,279 @@
import asyncio
import re
from base64 import b64encode
from datetime import datetime
from io import BytesIO
from math import ceil
from typing import Any
from urllib.parse import urlsplit
import qrcode
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Request, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import (
get_request_actor,
get_session,
validate_redirect_url,
verify_identity_admin,
verify_identity_token,
)
from api.v2.base_crud import generate_crud_router
from api.v2.routes.coupon_pricing import resolve_percent_coupon_pricing
from api.v2.schemas import KeyBase, KeyCreateRequest, KeyResponse, KeyUpdate
from api.v2.schemas.web_public import (
AccountKeyActionResponse,
AccountKeyActionsAvailability,
AccountKeyActionsConfigResponse,
AccountKeyAddonOptionResponse,
AccountKeyAddonsPreviewRequest,
AccountKeyAddonsPreviewResponse,
AccountKeyAliasUpdateRequest,
AccountKeyApplyAddonsResponse,
AccountKeyChangeLocationRequest,
AccountKeyChangeLocationResponse,
AccountKeyDetailsResponse,
AccountKeyLocationOptionResponse,
AccountKeyLocationsResponse,
AccountKeyQrResponse,
AccountKeyRenewRequest,
AccountKeyRenewResponse,
AccountKeyResetHwidResponse,
AccountKeyResponse,
)
from config import (
ENABLE_DELETE_KEY_BUTTON,
HWID_RESET_BUTTON,
INSTRUCTIONS_BUTTON,
QRCODE,
REMNAWAVE_LOGIN,
REMNAWAVE_PASSWORD,
USE_COUNTRY_SELECTION,
)
from core.bootstrap import BUTTONS_CONFIG, MODES_CONFIG, PAYMENTS_CONFIG, TARIFFS_CONFIG
from core.settings.tariffs_config import normalize_tariff_config
from database import (
check_server_name_by_cluster,
filter_cluster_by_subgroup,
get_balance,
get_key_details,
get_keys,
get_tariff_by_id,
identities as idb,
save_key_config_with_mode,
update_balance,
)
from database.access.resolution import resolve_user_optional
from database.coupons import mark_coupon_used
from database.models import Key, Server, ServerSpecialgroup, Tariff
from database.temporary_data import create_temporary_data
from handlers.buttons import CONNECT_DEVICE, ROUTER_BUTTON, TV_BUTTON
from handlers.keys.key_view import build_key_view_payload
from handlers.tariffs.addons.key_addons_pack import calc_pack_full_price_rub, get_pack_flags
from handlers.tariffs.addons.utils import calc_remaining_ratio_seconds, is_not_downgrade
from handlers.utils import ALLOWED_GROUP_CODES, is_full_remnawave_cluster
from logger import logger
from panels._3xui import delete_client, get_xui_instance
from panels.remnawave import RemnawaveAPI, get_vless_link_for_remnawave_by_username
from panels.remnawave_runtime import get_remnawave_profile, invalidate_remnawave_profile, with_remnawave_api
from services.operations import (
create_client_on_server,
create_key_on_cluster,
delete_key_from_cluster,
renew_key_in_cluster,
)
from services.operations.aggregated_links import make_aggregated_link
from services.payments.payment_links import PaymentLinkRequest, create_payment_link
from services.payments.providers import WEB_LINK_PROVIDER_IDS
from services.tariffs import calculate_config_price
from services.tariffs.tariff_display import GB, get_effective_limits_for_key, get_key_tariff_addons_state
router = generate_crud_router(
model=Key,
schema_response=KeyResponse,
schema_create=KeyBase,
schema_update=KeyUpdate,
identifier_field="tg_id",
extra_get_by_email=True,
enabled_methods=["get_all", "get_one", "get_by_email", "get_all_by_field"],
)
user_router = APIRouter()
def _key_actions_config() -> AccountKeyActionsConfigResponse:
addons_mode = str(TARIFFS_CONFIG.get("KEY_ADDONS_PACK_MODE", "") or "").strip().lower()
if addons_mode not in {"", "traffic", "devices", "all"}:
addons_mode = ""
addons_enabled_default = addons_mode in {"", "traffic", "devices", "all"}
return AccountKeyActionsConfigResponse(
renew_enabled=True,
delete_enabled=bool(BUTTONS_CONFIG.get("DELETE_KEY_BUTTON_ENABLE", ENABLE_DELETE_KEY_BUTTON)),
qr_enabled=bool(BUTTONS_CONFIG.get("QRCODE_BUTTON_ENABLE", QRCODE)),
hwid_reset_enabled=bool(BUTTONS_CONFIG.get("HWID_RESET_BUTTON_ENABLE", HWID_RESET_BUTTON)),
country_change_enabled=bool(MODES_CONFIG.get("COUNTRY_SELECTION_ENABLED", USE_COUNTRY_SELECTION)),
instructions_enabled=bool(BUTTONS_CONFIG.get("INSTRUCTIONS_BUTTON_ENABLE", INSTRUCTIONS_BUTTON)),
addons_enabled=addons_enabled_default,
addons_mode=addons_mode,
tv_connect_enabled=bool(BUTTONS_CONFIG.get("ANDROID_TV_BUTTON_ENABLE")),
)
def _extract_key_actions_from_markup(markup) -> AccountKeyActionsAvailability:
actions = AccountKeyActionsAvailability()
rows = getattr(markup, "inline_keyboard", None) or []
for row in rows:
for button in row:
callback_data = str(getattr(button, "callback_data", "") or "")
text = str(getattr(button, "text", "") or "")
has_url = bool(getattr(button, "url", None))
has_web_app = bool(getattr(button, "web_app", None))
if callback_data.startswith("connect_router|") or text == ROUTER_BUTTON:
actions.can_connect_router = True
if callback_data.startswith("connect_tv|") or text == TV_BUTTON:
actions.can_connect_tv = True
if callback_data.startswith("connect_device|") or (text == CONNECT_DEVICE and (has_url or has_web_app)):
actions.can_connect_device = True
if callback_data.startswith("renew_key|"):
actions.can_renew = True
if callback_data.startswith("key_addons|"):
actions.can_addons = True
if callback_data.startswith("reset_hwid|"):
actions.can_reset_hwid = True
if callback_data.startswith("show_qr|"):
actions.can_qr = True
if callback_data.startswith("delete_key|"):
actions.can_delete = True
if callback_data.startswith("change_location|"):
actions.can_change_location = True
return actions
async def _resolve_available_location_servers(session: AsyncSession, db_key: Key) -> list[str]:
current_server = str(getattr(db_key, "server_id", "") or "")
if not current_server:
return []
cluster_info = await check_server_name_by_cluster(session, current_server)
if not cluster_info:
return []
cluster_name = str(cluster_info.get("cluster_name") or "")
if not cluster_name:
return []
q = (
select(
Server.id,
Server.server_name,
Server.api_url,
Server.panel_type,
Server.enabled,
Server.max_keys,
)
.where(Server.cluster_name == cluster_name)
.where(Server.server_name != current_server)
)
servers = [dict(m) for m in (await session.execute(q)).mappings().all()]
if not servers:
return []
server_ids = [s["id"] for s in servers if s.get("id") is not None]
groups_map: dict[int, list[str]] = {}
if server_ids:
r = await session.execute(
select(ServerSpecialgroup.server_id, ServerSpecialgroup.group_code).where(
ServerSpecialgroup.server_id.in_(server_ids)
)
)
for sid, gc in r.all():
groups_map.setdefault(int(sid), []).append(gc)
for server in servers:
sid_raw = server.get("id")
sid = int(sid_raw) if sid_raw is not None else -1
server["special_groups"] = [g for g in groups_map.get(sid, []) if g in ALLOWED_GROUP_CODES]
key_tariff_id = getattr(db_key, "tariff_id", None)
subgroup_title = None
tariff_dict = None
if key_tariff_id:
tariff_dict = await get_tariff_by_id(session, int(key_tariff_id))
if tariff_dict:
subgroup_title = tariff_dict.get("subgroup_title")
available_servers = [s for s in servers if bool(s.get("enabled", True))]
if subgroup_title and available_servers:
filtered_servers = await filter_cluster_by_subgroup(
session=session,
cluster=available_servers,
target_subgroup=str(subgroup_title).strip(),
cluster_id=cluster_name,
tariff_id=int(key_tariff_id) if key_tariff_id else None,
)
if filtered_servers:
available_servers = filtered_servers
else:
available_servers = []
if available_servers and tariff_dict:
special = None
gc = str(tariff_dict.get("group_code") or "").lower()
if gc and gc in ALLOWED_GROUP_CODES:
special = gc
if special:
bound_servers = [s for s in available_servers if special in (s.get("special_groups") or [])]
if bound_servers:
available_servers = bound_servers
names = sorted(
{
str(s.get("server_name") or "").strip()
for s in available_servers
if str(s.get("server_name") or "").strip()
}
)
return names
async def _resolve_billing_user_id(request: Request, identity, session: AsyncSession) -> int:
actor = get_request_actor(request)
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
if billing_user_id is None:
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
return int(billing_user_id)
def _resolve_public_base_url(request: Request) -> str:
origin = str(request.headers.get("origin") or "").strip()
if origin.startswith(("http://", "https://")):
return origin.rstrip("/")
referer = str(request.headers.get("referer") or request.headers.get("referrer") or "").strip()
if referer.startswith(("http://", "https://")):
parsed = urlsplit(referer)
if parsed.scheme and parsed.netloc:
return f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
forwarded_host = str(request.headers.get("x-forwarded-host") or "").strip()
host = forwarded_host or str(request.headers.get("host") or "").strip()
forwarded_proto = str(request.headers.get("x-forwarded-proto") or "").split(",", 1)[0].strip().lower()
scheme = forwarded_proto if forwarded_proto in {"http", "https"} else request.url.scheme
if host:
return f"{scheme}://{host}".rstrip("/")
return str(request.base_url).rstrip("/")
def _resolve_default_web_payment_provider() -> str | None:
for provider_id in WEB_LINK_PROVIDER_IDS:
if bool(PAYMENTS_CONFIG.get(provider_id)):
return provider_id
return WEB_LINK_PROVIDER_IDS[0] if WEB_LINK_PROVIDER_IDS else None
def _normalize_expiry_ms(raw_value: int | float | None) -> int:
if not raw_value:
return 0
value = int(raw_value)
if value > 10**13:
value //= 1000
elif value < 10**10:
value *= 1000
return value
@@ -1,26 +1,5 @@
from datetime import datetime
from fastapi import Body, Depends, HTTPException, Path, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_identity_admin
from api.v2.schemas import KeyBase, KeyCreateRequest, KeyResponse, KeyUpdate
from api.v2.base_crud import generate_crud_router
from database.models import Key, Tariff
from handlers.keys.operations import create_key_on_cluster, delete_key_from_cluster, renew_key_in_cluster
from logger import logger
router = generate_crud_router(
model=Key,
schema_response=KeyResponse,
schema_create=KeyBase,
schema_update=KeyUpdate,
identifier_field="tg_id",
extra_get_by_email=True,
enabled_methods=["get_all", "get_one", "get_by_email", "get_all_by_field"],
)
from ._common import * # noqa: F401,F403
from ._common import router, user_router # noqa: F401
@router.delete("/by_email/{email}", response_model=dict)
async def delete_key_by_email(
@@ -60,7 +39,10 @@ async def get_router_keys_by_tg_id(
tariff_ids = [row[0] for row in tariffs_result.all()]
if not tariff_ids:
return []
keys_result = await session.execute(select(Key).where(Key.tg_id == tg_id, Key.tariff_id.in_(tariff_ids)))
u = await resolve_user_optional(session, tg_id)
if u is None:
return []
keys_result = await session.execute(select(Key).where(Key.user_id == u.id, Key.tariff_id.in_(tariff_ids)))
return keys_result.scalars().all()
@@ -132,3 +114,4 @@ async def create_key_api(
except Exception as e:
logger.error(f"[API] Ошибка при создании ключа: {e}")
raise HTTPException(status_code=500, detail="Ошибка при создании ключа")
+1
View File
@@ -0,0 +1 @@
from . import addons, core, hwid, location, renew # noqa: F401 — trigger registration
+619
View File
@@ -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))),
)
+249
View File
@@ -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="Подписка удалена")
+75
View File
@@ -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),
)
+230
View File
@@ -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),
)
+191
View File
@@ -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,
)
+24 -5
View File
@@ -3,10 +3,12 @@ import os
import re
import subprocess
import sys
from datetime import datetime, timezone, timedelta
from datetime import datetime, timedelta, timezone
from typing import Literal
import psutil
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
@@ -23,10 +25,11 @@ from api.v2.schemas.audit import (
)
from audit import drain_audit_redis_to_db, get_audit_funnel, get_audit_stats, list_audit_events
from config import API_TOKEN, BOT_SERVICE
from database import async_session_maker
from core.bootstrap import MANAGEMENT_CONFIG
from core.executor import run_io
from core.redis_cache import cache_incr
from core.settings.management_config import update_management_config
from database import async_session_maker
from database.models import Key, ScheduledBroadcast, Server, User
from database.scheduled_broadcasts import (
cancel_scheduled_broadcast,
@@ -48,9 +51,18 @@ from handlers.admin.sender.scheduled_service import (
from logger import logger
from utils.backup import backup_database
router = APIRouter()
async def _admin_rate_limit(request_or_identity, action: str, max_calls: int, window_sec: int) -> None:
identity_id = getattr(request_or_identity, "id", "unknown")
key = f"admin_rl:{action}:{identity_id}"
count = await cache_incr(key, window_sec)
if count > max_calls:
raise HTTPException(status_code=429, detail="Слишком много запросов. Попробуйте позже.")
class MaintenanceUpdate(BaseModel):
enabled: bool
@@ -174,6 +186,7 @@ async def restart_bot(
identity=Depends(verify_identity_admin),
):
"""Запуск перезапуска бота в фоне."""
await _admin_rate_limit(identity, "restart", max_calls=3, window_sec=60)
background.add_task(_restart_bot)
return {"status": "restarting"}
@@ -185,6 +198,7 @@ async def change_domain(
session: AsyncSession = Depends(get_session),
):
"""Массовая замена домена в ключах и remnawave_link."""
await _admin_rate_limit(identity, "change_domain", max_calls=3, window_sec=300)
domain = payload.domain.strip()
if not domain or " " in domain or not re.fullmatch(r"[a-zA-Z0-9.-]+", domain):
raise HTTPException(status_code=400, detail="Invalid domain")
@@ -211,11 +225,12 @@ async def restore_trials(
session: AsyncSession = Depends(get_session),
):
"""Сбрасывает trial=0 у пользователей без ключей."""
await _admin_rate_limit(identity, "restore_trials", max_calls=3, window_sec=300)
stmt = (
update(User)
.where(
User.trial == 1,
~exists(select(Key.tg_id).where(Key.tg_id == User.tg_id)),
~exists(select(Key.user_id).where(Key.user_id == User.id)),
)
.values(trial=0)
)
@@ -227,6 +242,7 @@ async def restore_trials(
@router.post("/backup")
async def trigger_backup(identity=Depends(verify_identity_admin)):
"""Запуск бэкапа БД в фоне."""
await _admin_rate_limit(identity, "backup", max_calls=2, window_sec=300)
async def _run_backup() -> None:
exception = await backup_database()
@@ -357,7 +373,7 @@ async def post_audit_drain(identity=Depends(verify_identity_admin_short)):
return {"success": True, "drained": count}
except Exception as exc:
logger.warning("audit-drain failed: {}", exc)
raise HTTPException(status_code=500, detail=str(exc)) from exc
raise HTTPException(status_code=500, detail="Внутренняя ошибка при дренаже аудита") from exc
@router.post("/broadcast")
@@ -366,6 +382,7 @@ async def launch_broadcast(
identity=Depends(verify_identity_admin_short),
):
"""Запуск рассылки по выбранной аудитории. Сессия БД не держится на время рассылки."""
await _admin_rate_limit(identity, "broadcast", max_calls=5, window_sec=300)
try:
prepared = prepare_broadcast_payload(
send_to=payload.send_to,
@@ -475,14 +492,16 @@ async def send_broadcast_schedule_now(
identity=Depends(verify_identity_admin_short),
session: AsyncSession = Depends(get_session),
):
await _admin_rate_limit(identity, "broadcast_now", max_calls=5, window_sec=300)
item = await start_scheduled_broadcast(session, broadcast_id)
if item is None:
raise HTTPException(status_code=409, detail="Scheduled broadcast can no longer be sent now")
try:
result = await execute_scheduled_broadcast(item, bot=_get_broadcast_bot())
except Exception as exc:
logger.error("[Broadcast] send-now failed for {}: {}", broadcast_id, exc)
await mark_scheduled_broadcast_failed(session, broadcast_id, str(exc))
raise HTTPException(status_code=500, detail=str(exc)) from exc
raise HTTPException(status_code=500, detail="Ошибка при выполнении рассылки") from exc
if result.get("success"):
item = await mark_scheduled_broadcast_sent(session, broadcast_id, result)
else:
+17 -5
View File
@@ -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",
+5 -1
View File
@@ -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)}
+83
View File
@@ -0,0 +1,83 @@
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_identity_token
from database.models import Identity
from database import web_notifications as wn_db
router = APIRouter()
class PushSubscribeRequest(BaseModel):
endpoint: str
keys: dict
class NotificationItem(BaseModel):
id: str
type: str
title: str
message: str
read: bool
created_at: str
data: dict | None = None
class NotificationsResponse(BaseModel):
ok: bool = True
notifications: list[NotificationItem]
unread_count: int
@router.post("/push/subscribe", tags=["Notifications"])
async def push_subscribe(
body: PushSubscribeRequest,
session: AsyncSession = Depends(get_session),
identity: Identity = Depends(verify_identity_token),
):
user_id = identity.tg_id or 0
await wn_db.upsert_push_subscription(
session,
user_id=user_id,
identity_id=identity.id,
endpoint=body.endpoint,
keys_json=body.keys,
)
return {"ok": True}
@router.get("/notifications", response_model=NotificationsResponse, tags=["Notifications"])
async def get_notifications(
limit: int = Query(20, ge=1, le=100),
session: AsyncSession = Depends(get_session),
identity: Identity = Depends(verify_identity_token),
):
notifications = await wn_db.get_notifications_for_identity(
session, identity.id, limit=limit,
)
unread_count = await wn_db.count_unread_for_identity(session, identity.id)
items = [
NotificationItem(
id=n.id,
type=n.type,
title=n.title,
message=n.message,
read=n.read,
created_at=n.created_at.isoformat() if n.created_at else "",
data=n.data,
)
for n in notifications
]
return NotificationsResponse(notifications=items, unread_count=unread_count)
@router.post("/notifications/read-all", tags=["Notifications"])
async def read_all_notifications(
session: AsyncSession = Depends(get_session),
identity: Identity = Depends(verify_identity_token),
):
count = await wn_db.mark_all_read_for_identity(session, identity.id)
return {"ok": True, "updated": count}
+465 -3
View File
@@ -1,14 +1,31 @@
import csv
from base64 import b64encode
from datetime import datetime
from io import StringIO
from io import BytesIO, StringIO
import re
from urllib.parse import urlsplit
from fastapi import APIRouter, Depends, Path, Query
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request
from fastapi.responses import JSONResponse, StreamingResponse
import qrcode
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_identity_admin
from api.depends import get_request_actor, get_session, verify_identity_admin, verify_identity_token
from api.v2.schemas.web_public import (
PartnerApplyRequest,
PartnerApplyResponse,
PartnerConditionsResponse,
PartnerQrResponse,
PartnerTopEntryResponse,
PartnerTopResponse,
PartnerPayoutEntryResponse,
PartnerPayoutHistoryResponse,
PartnerPayoutRequestCreate,
PartnerPayoutRequestResponse,
)
from database import identities as idb
from utils.referral_codes import decode_partner_code, encode_partner_code
try:
from modules.partner_program.settings import PARTNER_BONUS_PERCENTAGES
@@ -44,6 +61,451 @@ def _row_dt_iso(value) -> str | None:
return None
def _resolve_public_base_url(request: Request) -> str:
origin = str(request.headers.get("origin") or "").strip()
if origin.startswith("http://") or origin.startswith("https://"):
return origin.rstrip("/")
referer = str(request.headers.get("referer") or request.headers.get("referrer") or "").strip()
if referer.startswith("http://") or referer.startswith("https://"):
parsed = urlsplit(referer)
if parsed.scheme and parsed.netloc:
return f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
forwarded_host = str(request.headers.get("x-forwarded-host") or "").strip()
host = forwarded_host or str(request.headers.get("host") or "").strip()
forwarded_proto = str(request.headers.get("x-forwarded-proto") or "").split(",", 1)[0].strip().lower()
scheme = forwarded_proto if forwarded_proto in {"http", "https"} else request.url.scheme
if host:
return f"{scheme}://{host}".rstrip("/")
return str(request.base_url).rstrip("/")
async def _ensure_partner_code(session: AsyncSession, user_id: int, raw_code: str | None) -> str:
code = str(raw_code or "").strip()
if code and not code.isdigit() and not code.startswith("r1_"):
return code
generated = encode_partner_code(int(user_id))
try:
await session.execute(
text("UPDATE users SET partner_code = :code WHERE id = :id"),
{"code": generated, "id": int(user_id)},
)
await session.flush()
except Exception:
pass
return generated
async def _resolve_partner_user(session: AsyncSession, request: Request, identity) -> tuple[int, int]:
actor = get_request_actor(request)
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
if billing_user_id is None:
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
row = (
await session.execute(
text("SELECT id, tg_id FROM users WHERE id = :user_id LIMIT 1"),
{"user_id": int(billing_user_id)},
)
).first()
if row is None or row[1] is None:
raise HTTPException(status_code=400, detail="Партнерский профиль недоступен")
return int(row[0]), int(row[1])
async def _resolve_referrer_by_partner_code(session: AsyncSession, partner_code: str) -> tuple[int, int] | None:
code = str(partner_code or "").strip()
if not code:
return None
by_code_row = (
await session.execute(
text(
"""
SELECT id, tg_id
FROM users
WHERE lower(COALESCE(partner_code, '')) = lower(:code)
LIMIT 1
"""
),
{"code": code},
)
).first()
if by_code_row is not None:
user_id = int(by_code_row[0])
tg_id = int(by_code_row[1] if by_code_row[1] is not None else by_code_row[0])
return user_id, tg_id
decoded = decode_partner_code(code)
if decoded is None:
return None
by_id_row = (
await session.execute(
text("SELECT id, tg_id FROM users WHERE id = :id LIMIT 1"),
{"id": int(decoded)},
)
).first()
if by_id_row is not None:
user_id = int(by_id_row[0])
tg_id = int(by_id_row[1] if by_id_row[1] is not None else by_id_row[0])
return user_id, tg_id
by_tg_row = (
await session.execute(
text("SELECT id, tg_id FROM users WHERE tg_id = :tg_id LIMIT 1"),
{"tg_id": int(decoded)},
)
).first()
if by_tg_row is not None:
user_id = int(by_tg_row[0])
tg_id = int(by_tg_row[1] if by_tg_row[1] is not None else by_tg_row[0])
return user_id, tg_id
return None
@router.post("/apply", response_model=PartnerApplyResponse)
async def partner_apply(
body: PartnerApplyRequest,
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
joined_user_id, joined_tg_id = await _resolve_partner_user(session, request, identity)
code_value = str(body.partner_code or "").strip()
referrer_user_id: int | None = None
referrer_tg_id: int | None = None
if code_value:
resolved = await _resolve_referrer_by_partner_code(session, code_value)
if resolved is not None:
referrer_user_id, referrer_tg_id = resolved
if referrer_tg_id is None and body.partner_tg_id is not None:
referrer_tg_id = int(body.partner_tg_id)
referrer_user_id_row = (
await session.execute(
text("SELECT id FROM users WHERE tg_id = :tg_id LIMIT 1"),
{"tg_id": int(referrer_tg_id)},
)
).first()
if referrer_user_id_row is not None:
referrer_user_id = int(referrer_user_id_row[0])
if referrer_tg_id is None:
raise HTTPException(status_code=400, detail="Партнерский код не найден")
if int(referrer_tg_id) == int(joined_tg_id):
raise HTTPException(status_code=400, detail="Нельзя применить свой партнерский код")
already_row = (
await session.execute(
text("SELECT partner_tg_id FROM partners WHERE joined_tg_id = :joined_tg_id LIMIT 1"),
{"joined_tg_id": int(joined_tg_id)},
)
).first()
if already_row is not None and already_row[0] is not None:
raise HTTPException(status_code=409, detail="Партнер уже привязан")
await session.execute(
text(
"""
INSERT INTO partners (partner_tg_id, joined_tg_id)
VALUES (:partner_tg_id, :joined_tg_id)
"""
),
{"partner_tg_id": int(referrer_tg_id), "joined_tg_id": int(joined_tg_id)},
)
await session.commit()
return PartnerApplyResponse(
ok=True,
message="Партнерский код применен",
partner_code=code_value,
partner_user_id=int(referrer_user_id or 0),
partner_tg_id=int(referrer_tg_id),
joined_user_id=int(joined_user_id),
joined_tg_id=int(joined_tg_id),
)
@router.get("/qr", response_model=PartnerQrResponse)
async def partner_qr(
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
user_id, _ = await _resolve_partner_user(session, request, identity)
code_row = (
await session.execute(
text("SELECT partner_code FROM users WHERE id = :id LIMIT 1"),
{"id": int(user_id)},
)
).first()
partner_code = await _ensure_partner_code(session, int(user_id), code_row[0] if code_row else None)
base_url = _resolve_public_base_url(request)
partner_link = f"{base_url}/partner/{partner_code}"
qr = qrcode.QRCode(version=1, box_size=10, border=4)
qr.add_data(partner_link)
qr.make(fit=True)
image = qr.make_image(fill_color="black", back_color="white")
png_buffer = BytesIO()
image.save(png_buffer, format="PNG")
image_data = b64encode(png_buffer.getvalue()).decode("ascii")
return PartnerQrResponse(
ok=True,
link=partner_link,
image_data_url=f"data:image/png;base64,{image_data}",
)
@router.get("/top", response_model=PartnerTopResponse)
async def partner_top(
request: Request,
limit: int = Query(5, ge=1, le=20),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
_, joined_tg_id = await _resolve_partner_user(session, request, identity)
user_referred_count_row = (
await session.execute(
text(
"""
SELECT COUNT(DISTINCT joined_tg_id)
FROM partners
WHERE partner_tg_id = :partner_tg_id
"""
),
{"partner_tg_id": int(joined_tg_id)},
)
).first()
user_referred_count = int(user_referred_count_row[0] or 0) if user_referred_count_row else 0
user_position: int | None = None
if user_referred_count > 0:
user_position_row = (
await session.execute(
text(
"""
SELECT COUNT(*) + 1
FROM (
SELECT partner_tg_id, COUNT(DISTINCT joined_tg_id) AS referred_count
FROM partners
WHERE partner_tg_id IS NOT NULL
GROUP BY partner_tg_id
) ranked
WHERE ranked.referred_count > :referred_count
"""
),
{"referred_count": int(user_referred_count)},
)
).first()
user_position = int(user_position_row[0] or 1) if user_position_row else 1
top_rows = (
await session.execute(
text(
"""
SELECT
COALESCE(u.id, 0) AS partner_user_id,
p.partner_tg_id AS partner_tg_id,
COUNT(DISTINCT p.joined_tg_id) AS referred_count
FROM partners p
LEFT JOIN users u ON u.tg_id = p.partner_tg_id
WHERE p.partner_tg_id IS NOT NULL
GROUP BY p.partner_tg_id, u.id
ORDER BY referred_count DESC, p.partner_tg_id ASC
LIMIT :limit
"""
),
{"limit": int(limit)},
)
).all()
top: list[PartnerTopEntryResponse] = []
for index, row in enumerate(top_rows, 1):
partner_user_id = int(row[0] or 0)
partner_tg_id = int(row[1] or 0)
referred_count = int(row[2] or 0)
if partner_user_id > 0:
display_id = encode_partner_code(partner_user_id)
else:
tg_tail = str(partner_tg_id)
display_id = f"p_{tg_tail[:2]}***{tg_tail[-2:]}" if tg_tail else "p_***"
top.append(
PartnerTopEntryResponse(
position=index,
partner_user_id=partner_user_id,
referred_count=referred_count,
display_id=display_id,
)
)
return PartnerTopResponse(
user_referred_count=user_referred_count,
user_position=user_position,
top=top,
)
@router.get("/conditions", response_model=PartnerConditionsResponse)
async def partner_conditions(
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
try:
from modules.partner_program import settings as partner_settings
except Exception:
partner_settings = None
mode = str(getattr(partner_settings, "REFERRAL_REWARD_MODE", "percent_only") or "percent_only")
percent_levels_raw = getattr(partner_settings, "PARTNER_BONUS_PERCENTAGES", {}) or {}
flat_levels_raw = getattr(partner_settings, "PARTNER_FLAT_BONUSES", {}) or {}
min_payout = float(getattr(partner_settings, "MIN_PARTNER_PAYOUT", 0) or 0)
custom_amount_enabled = bool(getattr(partner_settings, "ENABLE_CUSTOM_WITHDRAW_AMOUNT", False))
method_map = [
("ENABLE_PAYOUT_CARD", "Карта"),
("ENABLE_PAYOUT_SBP", "СБП"),
("ENABLE_PAYOUT_USDT", "USDT"),
("ENABLE_PAYOUT_TON", "TON"),
]
payout_methods = [
title for key, title in method_map if bool(getattr(partner_settings, key, False))
] if partner_settings else []
level_lines: list[str] = []
all_levels = sorted({int(k) for k in [*percent_levels_raw.keys(), *flat_levels_raw.keys()] if str(k).isdigit()})
for level in all_levels:
parts: list[str] = []
if level in percent_levels_raw:
try:
parts.append(f"{float(percent_levels_raw[level]) * 100:.0f}%")
except Exception:
pass
if level in flat_levels_raw:
try:
parts.append(f"{float(flat_levels_raw[level]):.0f} RUB")
except Exception:
pass
if parts:
level_lines.append(f"{level} уровень: {' + '.join(parts)}")
if not level_lines:
level_lines = ["1 уровень: бонус определяется настройками проекта"]
mode_labels = {
"percent_only": "Процент с каждого пополнения приглашенного",
"flat_only": "Фиксированный бонус за первую оплату приглашенного",
"flat_plus_percent": "Фиксированный бонус за первую оплату и процент с пополнений",
}
rules = [
"Вознаграждение начисляется только после успешной оплаты приглашенного пользователя.",
"Самореферал и самопартнерство недоступны.",
f"Минимальная сумма вывода: {min_payout:.0f} RUB." if min_payout > 0 else "Вывод доступен по правилам проекта.",
]
if payout_methods:
rules.append(f"Доступные способы вывода: {', '.join(payout_methods)}.")
examples = [
"Пример: приглашенный пополнил на 1000 RUB, а ставка 15% — вы получаете 150 RUB.",
"Пример: приглашенный сделал несколько пополнений, бонус считается по каждой успешной операции.",
]
return PartnerConditionsResponse(
title="Условия партнерской программы",
summary="Актуальные условия и режим начислений для партнеров.",
bonus_mode=mode,
bonus_mode_label=mode_labels.get(mode, mode_labels["percent_only"]),
level_lines=level_lines,
rules=rules,
examples=examples,
min_payout_rub=min_payout,
payout_methods=payout_methods,
custom_amount_enabled=custom_amount_enabled,
)
@router.get("/payouts/me", response_model=PartnerPayoutHistoryResponse)
async def partner_payouts_me(
request: Request,
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
_, tg_id = await _resolve_partner_user(session, request, identity)
count_sql = text("SELECT COUNT(*) FROM payout_requests WHERE tg_id = :tg_id")
rows_sql = text(
"""
SELECT id, amount, status, created_at, method, destination
FROM payout_requests
WHERE tg_id = :tg_id
ORDER BY created_at DESC, id DESC
LIMIT :limit OFFSET :offset
"""
)
total = int((await session.scalar(count_sql, {"tg_id": tg_id})) or 0)
rows = (
await session.execute(rows_sql, {"tg_id": tg_id, "limit": int(limit), "offset": int(offset)})
).fetchall()
items = [
PartnerPayoutEntryResponse(
id=int(row[0]),
amount_rub=float(row[1] or 0.0),
status=str(row[2] or ""),
created_at=_row_dt_iso(row[3]),
method=row[4] or None,
destination=row[5] or None,
)
for row in rows
]
return PartnerPayoutHistoryResponse(total=total, items=items)
@router.post("/payouts/me", response_model=PartnerPayoutRequestResponse)
async def partner_create_payout_request(
body: PartnerPayoutRequestCreate,
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
user_id, tg_id = await _resolve_partner_user(session, request, identity)
row = (
await session.execute(
text("SELECT COALESCE(partner_balance, 0), payout_method, card_number FROM users WHERE id = :id"),
{"id": user_id},
)
).first()
balance = float(row[0] or 0.0) if row else 0.0
requested = float(body.amount_rub)
if requested <= 0:
raise HTTPException(status_code=400, detail="Сумма должна быть больше нуля")
try:
from modules.partner_program.settings import ENABLE_CUSTOM_WITHDRAW_AMOUNT, MIN_PARTNER_PAYOUT
except Exception:
ENABLE_CUSTOM_WITHDRAW_AMOUNT = True
MIN_PARTNER_PAYOUT = 0
min_payout = float(MIN_PARTNER_PAYOUT or 0)
if requested < min_payout:
raise HTTPException(status_code=400, detail=f"Минимальная сумма вывода — {min_payout:.0f} RUB")
if not bool(ENABLE_CUSTOM_WITHDRAW_AMOUNT):
requested = balance
if requested > balance:
raise HTTPException(status_code=400, detail="Недостаточно партнерского баланса")
if requested <= 0:
raise HTTPException(status_code=400, detail="Недостаточно средств для заявки")
payout_method = (row[1] if row else None) or "card"
destination = (row[2] if row else None) or None
inserted = (
await session.execute(
text(
"""
INSERT INTO payout_requests (tg_id, amount, status, created_at, method, destination)
VALUES (:tg_id, :amount, 'pending', NOW(), :method, :destination)
RETURNING id
"""
),
{
"tg_id": int(tg_id),
"amount": float(requested),
"method": payout_method,
"destination": destination,
},
)
).scalar()
new_balance = balance - requested
await session.execute(
text("UPDATE users SET partner_balance = :balance WHERE id = :id"),
{"balance": new_balance, "id": int(user_id)},
)
await session.commit()
return PartnerPayoutRequestResponse(
ok=True,
message="Заявка на вывод создана",
request_id=int(inserted) if inserted is not None else None,
amount_rub=float(requested),
status="pending",
balance_rub=float(new_balance),
)
@router.get("/all")
async def get_all_partners(
limit: int = Query(1000, ge=1, le=10000, description="Лимит результатов"),
+240 -23
View File
@@ -1,28 +1,143 @@
import asyncio
import json
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_identity_token
from api.v2.schemas.payment_links import PaymentLinkCreateRequest, PaymentLinkCreateResponse
from database import identities as idb
from handlers.payments import create_payment_link
from handlers.payments.payment_links import PaymentLinkRequest
from api.v2.schemas.payment_links import PaymentLinkCreateRequest, PaymentLinkCreateResponse, PaymentLinkStatusResponse
from config import REDIS_URL
from database import (
async_session_maker,
get_payment_by_payment_id,
get_payment_from_db_by_payment_id,
identities as idb,
)
from database.temporary_data import create_temporary_data
from logger import logger
from services.payments.payment_events import payment_events_channel
from services.payments.payment_links import PaymentLinkRequest, create_payment_link
router = APIRouter(tags=["PaymentLinks"])
async def _resolve_tg_id(body: PaymentLinkCreateRequest, session: AsyncSession) -> int:
"""Возвращает tg_id из body.tg_id или из identity_id; иначе исключение."""
if body.tg_id is not None:
return body.tg_id
if body.identity_id:
tg_id = await idb.resolve_tg_id(session, body.identity_id)
if tg_id is not None:
return tg_id
raise HTTPException(
status_code=400,
detail="У идентичности не привязан Telegram. Привяжите tg_id для создания платёжной ссылки.",
)
raise HTTPException(status_code=400, detail="Укажите tg_id или identity_id")
async def _store_payment_intent(
session: AsyncSession,
billing_user_ref: int,
metadata: dict | None,
amount: int | float,
) -> None:
if not isinstance(metadata, dict):
return
payment_flow = str(metadata.get("payment_flow") or "").strip().lower()
required_amount = int(round(float(amount)))
if payment_flow == "tariff_purchase":
tariff_id = metadata.get("tariff_id")
if tariff_id in (None, ""):
return
payload: dict[str, int | str] = {
"tariff_id": int(tariff_id),
"required_amount": required_amount,
"selected_price_rub": int(metadata.get("selected_price_rub") or required_amount),
}
selected_device_limit = metadata.get("selected_device_limit")
if selected_device_limit not in (None, ""):
payload["selected_device_limit"] = int(selected_device_limit)
selected_traffic_gb = metadata.get("selected_traffic_gb")
if selected_traffic_gb not in (None, ""):
payload["selected_traffic_limit_gb"] = int(selected_traffic_gb)
selected_duration_days = metadata.get("selected_duration_days")
if selected_duration_days not in (None, ""):
payload["selected_duration_days"] = int(selected_duration_days)
coupon_id = metadata.get("coupon_id")
if coupon_id not in (None, ""):
payload["coupon_id"] = int(coupon_id)
discount_rub = metadata.get("discount_rub")
if discount_rub not in (None, ""):
payload["discount_rub"] = int(discount_rub)
base_price_rub = metadata.get("base_price_rub")
if base_price_rub not in (None, ""):
payload["base_price_rub"] = int(base_price_rub)
applied_coupon_code = metadata.get("applied_coupon_code")
if applied_coupon_code not in (None, ""):
payload["applied_coupon_code"] = str(applied_coupon_code)
await create_temporary_data(session, billing_user_ref, "waiting_for_payment", payload)
return
if payment_flow == "key_renewal":
required_fields = ("tariff_id", "client_id", "email", "cost")
if any(metadata.get(field) in (None, "") for field in required_fields):
return
payload: dict[str, int | str] = {
"tariff_id": int(metadata["tariff_id"]),
"client_id": str(metadata["client_id"]),
"email": str(metadata["email"]),
"cost": int(metadata["cost"]),
"required_amount": required_amount,
"selected_price_rub": int(metadata.get("selected_price_rub") or metadata["cost"]),
}
selected_duration_days = metadata.get("selected_duration_days")
if selected_duration_days not in (None, ""):
payload["selected_duration_days"] = int(selected_duration_days)
selected_device_limit = metadata.get("selected_device_limit")
if selected_device_limit not in (None, ""):
payload["selected_device_limit"] = int(selected_device_limit)
selected_traffic_limit = metadata.get("selected_traffic_limit")
if selected_traffic_limit not in (None, ""):
payload["selected_traffic_limit"] = int(selected_traffic_limit)
total_gb = metadata.get("total_gb")
if total_gb not in (None, ""):
payload["total_gb"] = int(total_gb)
coupon_id = metadata.get("coupon_id")
if coupon_id not in (None, ""):
payload["coupon_id"] = int(coupon_id)
discount_rub = metadata.get("discount_rub")
if discount_rub not in (None, ""):
payload["discount_rub"] = int(discount_rub)
base_price_rub = metadata.get("base_price_rub")
if base_price_rub not in (None, ""):
payload["base_price_rub"] = int(base_price_rub)
applied_coupon_code = metadata.get("applied_coupon_code")
if applied_coupon_code not in (None, ""):
payload["applied_coupon_code"] = str(applied_coupon_code)
await create_temporary_data(session, billing_user_ref, "waiting_for_renewal_payment", payload)
return
if payment_flow == "key_addons":
required_fields = ("tariff_id", "email", "original_price")
if any(metadata.get(field) in (None, "") for field in required_fields):
return
payload: dict[str, int | str] = {
"tariff_id": int(metadata["tariff_id"]),
"email": str(metadata["email"]),
"original_price": int(metadata["original_price"]),
"required_amount": required_amount,
}
selected_device_limit = metadata.get("selected_device_limit")
if selected_device_limit not in (None, ""):
payload["selected_device_limit"] = int(selected_device_limit)
selected_traffic_gb = metadata.get("selected_traffic_gb")
if selected_traffic_gb not in (None, ""):
payload["selected_traffic_gb"] = int(selected_traffic_gb)
current_device_limit = metadata.get("current_device_limit")
if current_device_limit not in (None, ""):
payload["current_device_limit"] = int(current_device_limit)
current_traffic_gb = metadata.get("current_traffic_gb")
if current_traffic_gb not in (None, ""):
payload["current_traffic_gb"] = int(current_traffic_gb)
coupon_id = metadata.get("coupon_id")
if coupon_id not in (None, ""):
payload["coupon_id"] = int(coupon_id)
discount_rub = metadata.get("discount_rub")
if discount_rub not in (None, ""):
payload["discount_rub"] = int(discount_rub)
base_price_rub = metadata.get("base_price_rub")
if base_price_rub not in (None, ""):
payload["base_price_rub"] = int(base_price_rub)
applied_coupon_code = metadata.get("applied_coupon_code")
if applied_coupon_code not in (None, ""):
payload["applied_coupon_code"] = str(applied_coupon_code)
await create_temporary_data(session, billing_user_ref, "waiting_for_addons_payment", payload)
@router.post("/", response_model=PaymentLinkCreateResponse)
@@ -32,13 +147,10 @@ async def create_link(
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
"""Создаёт платёжную ссылку через выбранную кассу (единая точка входа). Принимает identity_id или tg_id."""
try:
tg_id = await _resolve_tg_id(body, session)
except HTTPException:
raise
"""Создаёт платёжную ссылку для текущего авторизованного пользователя."""
billing_user_ref = await idb.ensure_billing_user_for_identity(session, identity)
payment_request = PaymentLinkRequest(
tg_id=tg_id,
legacy_user_ref=billing_user_ref,
amount=body.amount,
currency=body.currency or "RUB",
provider_id=body.provider_id,
@@ -47,9 +159,114 @@ async def create_link(
metadata=body.metadata,
)
result = await create_payment_link(session, payment_request)
if result.success:
await _store_payment_intent(
session=session,
billing_user_ref=billing_user_ref,
metadata=body.metadata,
amount=body.amount,
)
return PaymentLinkCreateResponse(
success=result.success,
payment_id=result.payment_id,
payment_url=result.payment_url,
error=result.error,
)
@router.get("/stream")
async def payment_events_stream(
request: Request,
x_identity_id: str = "",
token: str = "",
):
identity_id = str(request.headers.get("X-Identity-Id") or x_identity_id or "").strip()
token = str(request.headers.get("X-Token") or token or "").strip()
if not identity_id or not token:
raise HTTPException(status_code=401, detail="Unauthorized")
async with async_session_maker() as session:
identity = await idb.verify_identity_token(session, identity_id, token)
if not identity:
raise HTTPException(status_code=401, detail="Unauthorized")
billing_user_ref = await idb.ensure_billing_user_for_identity(session, identity)
await session.commit()
async def event_generator():
redis_client = None
pubsub = None
channel = payment_events_channel(int(billing_user_ref))
try:
from redis.asyncio import from_url
redis_client = from_url(REDIS_URL, encoding="utf-8", decode_responses=True, max_connections=8)
pubsub = redis_client.pubsub(ignore_subscribe_messages=True)
await pubsub.subscribe(channel)
logger.info(f"[Payments] SSE subscribed: user_ref={billing_user_ref}, channel={channel}")
yield "retry: 1500\n\n"
while True:
if await request.is_disconnected():
logger.info(f"[Payments] SSE disconnected by client: user_ref={billing_user_ref}")
break
message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=15.0)
if message and message.get("type") == "message":
raw_data = message.get("data")
payload = json.loads(raw_data) if isinstance(raw_data, str) else raw_data
if isinstance(payload, dict):
logger.info(
f"[Payments] SSE emit: user_ref={billing_user_ref}, "
f"status={payload.get('status')}, flow={payload.get('flow')}"
)
yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
continue
yield ": keepalive\n\n"
await asyncio.sleep(0.1)
finally:
if pubsub is not None:
try:
await pubsub.unsubscribe(channel)
await pubsub.close()
except Exception:
pass
if redis_client is not None:
try:
await redis_client.aclose()
except Exception:
pass
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@router.get("/{payment_id}", response_model=PaymentLinkStatusResponse)
async def get_link_status(
payment_id: str,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
billing_user_ref = await idb.ensure_billing_user_for_identity(session, identity)
payment = await get_payment_from_db_by_payment_id(session, payment_id)
if payment is None:
payment = await get_payment_by_payment_id(session, payment_id)
if not payment:
raise HTTPException(status_code=404, detail="Payment not found")
owner_ref = payment.get("user_id")
if owner_ref is None:
owner_ref = payment.get("tg_id")
if owner_ref is None or int(owner_ref) != int(billing_user_ref):
raise HTTPException(status_code=404, detail="Payment not found")
status = str(payment.get("status") or "").lower() or None
return PaymentLinkStatusResponse(
success=True,
payment_id=payment_id,
status=status,
completed=status in {"success", "failed", "cancelled"},
paid=status == "success",
)
+179 -29
View File
@@ -1,37 +1,187 @@
from fastapi import Depends, HTTPException, Query
from sqlalchemy import select
from base64 import b64encode
from io import BytesIO
from urllib.parse import urlsplit
import qrcode
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_identity_admin
from api.v2.base_crud import generate_crud_router
from api.v2.schemas import ReferralResponse
from database.models import Referral
router = generate_crud_router(
model=Referral,
schema_response=ReferralResponse,
schema_create=None,
schema_update=None,
identifier_field="referrer_tg_id",
parameter_name="referrer_tg_id",
enabled_methods=["get_all", "get_one", "get_all_by_field"],
from api.depends import get_session, verify_identity_token
from api.v2.schemas.web_public import (
ReferralApplyRequest,
ReferralApplyResponse,
ReferralConditionsResponse,
ReferralQrResponse,
ReferralTopEntryResponse,
ReferralTopResponse,
)
from config import CHECK_REFERRAL_REWARD_ISSUED, REFERRAL_BONUS_PERCENTAGES, REFERRAL_BUTTON, REFERRAL_QR, TOP_REFERRAL_BUTTON
from core.bootstrap import BUTTONS_CONFIG
from database import add_referral, get_referral_by_referred_id, get_user_referral_count
from database.referrals import get_referral_position, get_top_referrals
from database import identities as idb
from database.access.resolution import resolve_user_optional
from utils.referral_codes import decode_referral_code, encode_referral_code
router = APIRouter()
@router.delete("/one")
async def delete_one_referral(
referrer_tg_id: int = Query(..., description="ID пригласившего"),
referred_tg_id: int = Query(..., description="ID приглашённого"),
identity=Depends(verify_identity_admin),
def _normalize_referrer_code(value: str | None, fallback_tg_id: int | None) -> int | None:
raw = str(value or "").strip()
if raw:
if "/referral/" in raw:
raw = raw.split("/referral/", 1)[-1]
if "start=referral_" in raw:
raw = raw.split("start=referral_", 1)[-1]
raw = raw.split("?", 1)[0].split("#", 1)[0].strip()
parsed = decode_referral_code(raw)
if parsed is not None:
return parsed
if fallback_tg_id is not None and int(fallback_tg_id) > 0:
return int(fallback_tg_id)
return None
def _resolve_public_base_url(request: Request) -> str:
origin = str(request.headers.get("origin") or "").strip()
if origin.startswith("http://") or origin.startswith("https://"):
return origin.rstrip("/")
referer = str(request.headers.get("referer") or request.headers.get("referrer") or "").strip()
if referer.startswith("http://") or referer.startswith("https://"):
parsed = urlsplit(referer)
if parsed.scheme and parsed.netloc:
return f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
forwarded_host = str(request.headers.get("x-forwarded-host") or "").strip()
host = forwarded_host or str(request.headers.get("host") or "").strip()
forwarded_proto = str(request.headers.get("x-forwarded-proto") or "").split(",", 1)[0].strip().lower()
scheme = forwarded_proto if forwarded_proto in {"http", "https"} else request.url.scheme
if host:
return f"{scheme}://{host}".rstrip("/")
return str(request.base_url).rstrip("/")
@router.post("/apply", response_model=ReferralApplyResponse, tags=["Referrals"])
async def apply_referral(
body: ReferralApplyRequest,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
"""Удаляет одну связь реферала по паре referrer/referred."""
result = await session.execute(
select(Referral).where(Referral.referrer_tg_id == referrer_tg_id, Referral.referred_tg_id == referred_tg_id)
if not bool(BUTTONS_CONFIG.get("REFERRAL_BUTTON_ENABLED", REFERRAL_BUTTON)):
raise HTTPException(status_code=403, detail="Реферальная программа отключена")
billing_uid = await idb.ensure_billing_user_for_identity(session, identity)
referrer_legacy = _normalize_referrer_code(body.referrer_code, body.referrer_tg_id)
if referrer_legacy is None:
raise HTTPException(status_code=400, detail="Приглашение недействительно")
referrer_u = await resolve_user_optional(session, referrer_legacy)
if referrer_u is None:
raise HTTPException(status_code=400, detail="Приглашение недействительно")
if billing_uid == referrer_u.id:
raise HTTPException(status_code=400, detail="Нельзя использовать собственную ссылку")
if await get_referral_by_referred_id(session, billing_uid):
raise HTTPException(status_code=409, detail="Реферальная связь уже сохранена")
await add_referral(session, billing_uid, referrer_u.id)
referred_u = await resolve_user_optional(session, billing_uid)
return ReferralApplyResponse(
ok=True,
message="Приглашение применено",
referrer_code=str(referrer_u.id),
referrer_user_id=int(referrer_u.id),
referrer_tg_id=referrer_u.tg_id,
referred_user_id=int(billing_uid),
referred_tg_id=referred_u.tg_id if referred_u is not None else None,
)
@router.get("/top", response_model=ReferralTopResponse, tags=["Referrals"])
async def referral_top(
limit: int = Query(5, ge=1, le=20),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
if not bool(BUTTONS_CONFIG.get("REFERRAL_BUTTON_ENABLED", REFERRAL_BUTTON)):
raise HTTPException(status_code=403, detail="Реферальная программа отключена")
if not bool(BUTTONS_CONFIG.get("TOP_REFERRAL_BUTTON_ENABLE", TOP_REFERRAL_BUTTON)):
raise HTTPException(status_code=403, detail="Топ рефералов отключен в настройках")
billing_uid = await idb.ensure_billing_user_for_identity(session, identity)
user_referral_count = int(await get_user_referral_count(session, billing_uid))
user_position = int(await get_referral_position(session, user_referral_count)) if user_referral_count > 0 else None
top_rows = await get_top_referrals(session, limit=limit)
top: list[ReferralTopEntryResponse] = []
for index, row in enumerate(top_rows, 1):
referrer_user_id = int(row.get("referrer_user_id") or 0)
referrals_count = int(row.get("referral_count") or 0)
display_id = encode_referral_code(referrer_user_id)
top.append(
ReferralTopEntryResponse(
position=index,
referrer_user_id=referrer_user_id,
referrals_count=referrals_count,
display_id=display_id,
)
)
return ReferralTopResponse(
user_referrals_count=user_referral_count,
user_position=user_position,
top=top,
)
@router.get("/qr", response_model=ReferralQrResponse, tags=["Referrals"])
async def referral_qr(
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
if not bool(BUTTONS_CONFIG.get("REFERRAL_BUTTON_ENABLED", REFERRAL_BUTTON)):
raise HTTPException(status_code=403, detail="Реферальная программа отключена")
if not bool(BUTTONS_CONFIG.get("REFERRAL_QR_BUTTON_ENABLE", REFERRAL_QR)):
raise HTTPException(status_code=403, detail="QR реферальной ссылки отключен в настройках")
billing_uid = await idb.ensure_billing_user_for_identity(session, identity)
base_url = _resolve_public_base_url(request)
referral_link = f"{base_url}/referral/{encode_referral_code(int(billing_uid))}"
qr = qrcode.QRCode(version=1, box_size=10, border=4)
qr.add_data(referral_link)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buffer = BytesIO()
img.save(buffer, format="PNG")
image_data = b64encode(buffer.getvalue()).decode("ascii")
return ReferralQrResponse(
ok=True,
link=referral_link,
image_data_url=f"data:image/png;base64,{image_data}",
)
@router.get("/conditions", response_model=ReferralConditionsResponse, tags=["Referrals"])
async def referral_conditions(
identity=Depends(verify_identity_token),
):
if not bool(BUTTONS_CONFIG.get("REFERRAL_BUTTON_ENABLED", REFERRAL_BUTTON)):
raise HTTPException(status_code=403, detail="Реферальная программа отключена")
del identity
level_lines: list[str] = []
for level in sorted(REFERRAL_BONUS_PERCENTAGES.keys()):
value = REFERRAL_BONUS_PERCENTAGES[level]
if isinstance(value, float):
label = f"{int(value * 100)}% от суммы оплаты"
else:
label = f"{float(value):g} RUB"
level_lines.append(f"{level} уровень: {label}")
one_time_mode = bool(CHECK_REFERRAL_REWARD_ISSUED)
bonus_mode = "one_time" if one_time_mode else "each_payment"
bonus_mode_label = "Бонус за первую успешную оплату реферала" if one_time_mode else "Бонус за каждую успешную оплату реферала"
rules = [
"Бонус начисляется только за реальных приглашённых пользователей.",
"Нельзя использовать собственную реферальную ссылку.",
"Реферальную связь можно применить только один раз.",
"Размер бонуса зависит от уровня реферальной программы.",
]
return ReferralConditionsResponse(
title="Условия реферальной программы",
summary=f"Режим начисления: {bonus_mode_label}.",
bonus_mode=bonus_mode,
bonus_mode_label=bonus_mode_label,
level_lines=level_lines,
rules=rules,
)
obj = result.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail="Referral not found")
await session.delete(obj)
await session.commit()
return {"status": "deleted_one"}
+138 -1
View File
@@ -1,10 +1,63 @@
from fastapi import APIRouter
from config import PROJECT_NAME, USERNAME_BOT
from config import (
BALANCE_BUTTON,
CAPTCHA_ENABLE,
CHANNEL_EXISTS,
CHANNEL_REQUIRED,
DONATIONS_ENABLE,
GIFT_BUTTON,
HAPP_CRYPTOLINK,
HWID_RESET_BUTTON,
INSTRUCTIONS_BUTTON,
PROJECT_NAME,
REFERRAL_BUTTON,
REFERRAL_QR,
REMNAWAVE_WEBAPP,
REMNAWAVE_WEBAPP_OPEN_IN_BROWSER,
TOP_REFERRAL_BUTTON,
TRIAL_TIME_DISABLE,
USE_COUNTRY_SELECTION,
USERNAME_BOT,
TELEGRAM_WEBAPP_DIRECT_LINK,
TELEGRAM_WEBAPP_SHORT_NAME,
)
from core.bootstrap import BUTTONS_CONFIG, MODES_CONFIG, MONEY_CONFIG, PAYMENTS_CONFIG
from core.settings.web_config import WEB_CONFIG
from core.settings.money_config import get_currency_mode
from services.payments.providers import PROVIDERS_BASE, TELEGRAM_ONLY_PROVIDER_IDS, WEB_LINK_PROVIDER_IDS
router = APIRouter(tags=["Root"])
def _telegram_web_app_return_base() -> str | None:
direct = str(TELEGRAM_WEBAPP_DIRECT_LINK or "").strip().rstrip("/")
if direct:
if direct.lower().startswith("http://"):
direct = "https://" + direct[7:]
if direct.lower().startswith("https://t.me/"):
return direct
bot = USERNAME_BOT.replace("@", "").strip()
sn = str(TELEGRAM_WEBAPP_SHORT_NAME or "").strip()
if bot and sn:
return f"https://t.me/{bot}/{sn}"
if bot:
return f"https://t.me/{bot}"
return None
def _partner_feature_enabled() -> bool:
try:
from modules.partner_program import settings as partner_settings
except Exception:
return False
for key in ("PARTNER_PROGRAM_ENABLED", "PARTNER_BUTTON_ENABLED", "PARTNER_ENABLED"):
value = getattr(partner_settings, key, None)
if isinstance(value, bool):
return value
return True
@router.get("/api", include_in_schema=False)
async def root():
return {"message": "SoloBot API v2", "docs": "/api/docs"}
@@ -22,3 +75,87 @@ async def telegram_widget_bot():
"bot_username": USERNAME_BOT.replace("@", ""),
"project_name": (PROJECT_NAME or "Solo").strip() if isinstance(PROJECT_NAME, str) else "Solo",
}
@router.get("/api/site-config", include_in_schema=True)
async def site_config():
"""Настройки витрины и кабинета для веб-клиента (флаги из runtime-конфигов бота)."""
bot_username = USERNAME_BOT.replace("@", "").strip()
pay_flags = {name: bool(PAYMENTS_CONFIG.get(name)) for name in PROVIDERS_BASE}
any_pay = any(pay_flags.values())
web_link_provider_ids = [provider_id for provider_id in WEB_LINK_PROVIDER_IDS if pay_flags.get(provider_id, False)]
telegram_only_provider_ids = [
provider_id for provider_id in TELEGRAM_ONLY_PROVIDER_IDS if pay_flags.get(provider_id, False)
]
currency_mode, currency_one_screen = get_currency_mode()
try:
cb_raw = MONEY_CONFIG.get("CASHBACK", 0)
cashback_percent = float(cb_raw) if cb_raw not in (None, False) else 0.0
except (TypeError, ValueError):
cashback_percent = 0.0
webapp_short = str(TELEGRAM_WEBAPP_SHORT_NAME or "").strip() or None
webapp_return_base = _telegram_web_app_return_base()
return {
"bot_username": bot_username or None,
"telegram_web_app_short_name": webapp_short,
"telegram_web_app_return_base": webapp_return_base,
"project_name": (PROJECT_NAME or "Solo").strip() if isinstance(PROJECT_NAME, str) else "Solo",
"site_mode": str(WEB_CONFIG.get("SITE_MODE", "full")).strip() or "full",
"auth": {
"telegram_login_enabled": bool(bot_username),
"email_code_login_enabled": bool(MODES_CONFIG.get("WEB_EMAIL_CODE_LOGIN_ENABLED", True)),
},
"mobile": {
"prefer_mini_app_on_telegram_mobile": bool(
MODES_CONFIG.get("PREFER_MINI_APP_ON_TELEGRAM_MOBILE", False)
),
},
"features": {
"channel_enabled": bool(BUTTONS_CONFIG.get("CHANNEL_BUTTON_ENABLE", CHANNEL_EXISTS)),
"donations_enabled": bool(BUTTONS_CONFIG.get("DONATIONS_BUTTON_ENABLE", DONATIONS_ENABLE)),
"balance_enabled": bool(BUTTONS_CONFIG.get("BALANCE_BUTTON_ENABLE", BALANCE_BUTTON)),
"referral_qr_enabled": bool(BUTTONS_CONFIG.get("REFERRAL_QR_BUTTON_ENABLE", REFERRAL_QR)),
"instructions_enabled": bool(BUTTONS_CONFIG.get("INSTRUCTIONS_BUTTON_ENABLE", INSTRUCTIONS_BUTTON)),
"gift_enabled": bool(BUTTONS_CONFIG.get("GIFT_BUTTON_ENABLE", GIFT_BUTTON)),
"referral_enabled": bool(BUTTONS_CONFIG.get("REFERRAL_BUTTON_ENABLED", REFERRAL_BUTTON)),
"top_referral_enabled": bool(BUTTONS_CONFIG.get("TOP_REFERRAL_BUTTON_ENABLE", TOP_REFERRAL_BUTTON)),
"coupon_enabled": bool(BUTTONS_CONFIG.get("COUPON_BUTTON_ENABLE", True)),
"qr_subscription_enabled": bool(MODES_CONFIG.get("HAPP_CRYPTOLINK_ENABLED", HAPP_CRYPTOLINK)),
"hwid_reset_enabled": bool(BUTTONS_CONFIG.get("HWID_RESET_BUTTON_ENABLE", HWID_RESET_BUTTON)),
"country_selection_enabled": bool(
MODES_CONFIG.get("COUNTRY_SELECTION_ENABLED", USE_COUNTRY_SELECTION)
),
"captcha_enabled": bool(MODES_CONFIG.get("CAPTCHA_ENABLED", CAPTCHA_ENABLE)),
"channel_check_enabled": bool(MODES_CONFIG.get("CHANNEL_CHECK_ENABLED", CHANNEL_REQUIRED)),
"trial_enabled": not bool(MODES_CONFIG.get("TRIAL_TIME_DISABLED", TRIAL_TIME_DISABLE)),
"mini_app_enabled": bool(MODES_CONFIG.get("REMNAWAVE_WEBAPP_ENABLED", REMNAWAVE_WEBAPP)),
"mini_app_open_in_browser": bool(
MODES_CONFIG.get("REMNAWAVE_WEBAPP_OPEN_IN_BROWSER", REMNAWAVE_WEBAPP_OPEN_IN_BROWSER)
),
"partner_enabled": bool(_partner_feature_enabled()),
},
"payments": {
"any_enabled": any_pay,
"any_web_link_enabled": bool(web_link_provider_ids),
"any_telegram_only_enabled": bool(telegram_only_provider_ids),
"web_link_provider_ids": web_link_provider_ids,
"telegram_only_provider_ids": telegram_only_provider_ids,
"yookassa_enabled": pay_flags.get("YOOKASSA", False),
"yoomoney_enabled": pay_flags.get("YOOMONEY", False),
"robokassa_enabled": pay_flags.get("ROBOKASSA", False),
"kassai_cards_enabled": pay_flags.get("KASSAI_CARDS", False),
"kassai_sbp_enabled": pay_flags.get("KASSAI_SBP", False),
"tribute_enabled": pay_flags.get("TRIBUTE", False),
"heleket_enabled": pay_flags.get("HELEKET", False),
"cryptobot_enabled": pay_flags.get("CRYPTOBOT", False),
"freekassa_enabled": pay_flags.get("FREEKASSA", False),
"stars_enabled": pay_flags.get("STARS", False),
},
"money": {
"currency_mode": currency_mode,
"currency_one_screen": currency_one_screen,
"cashback_enabled": cashback_percent > 0,
"cashback_percent": cashback_percent,
},
}
+378 -11
View File
@@ -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(
+6 -2
View File
@@ -9,7 +9,8 @@ from api.v2.schemas import UserBase, UserResponse, UserUpdate
from api.v2.base_crud import generate_crud_router
from database import async_session_maker, delete_user_data, get_servers
from database.models import Key, User
from handlers.keys.operations import delete_key_from_cluster
from database.access.resolution import resolve_user_optional
from services.operations import delete_key_from_cluster
from logger import logger
router = generate_crud_router(
@@ -30,7 +31,10 @@ async def delete_user(
):
"""Удаляет пользователя и его ключи на серверах."""
try:
result = await session.execute(select(Key.email, Key.client_id).where(Key.tg_id == tg_id))
u = await resolve_user_optional(session, tg_id)
if u is None:
raise HTTPException(status_code=404, detail="Пользователь не найден")
result = await session.execute(select(Key.email, Key.client_id).where(Key.user_id == u.id))
key_records = result.all()
async with async_session_maker() as s:
+614 -42
View File
@@ -1,20 +1,72 @@
import re
import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
from pydantic import BaseModel
from sqlalchemy import select, delete
from datetime import datetime, timedelta, timezone
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_identity_admin
from api.v2.schemas import WebPageResponse, WebPageUpdate, WebBlockResponse, WebTheme
from api.v2.schemas.web import WebUploadResponse
from database.models import WebPage, WebBlock, WebTheme as WebThemeModel
from api.v2.schemas import WebBlockResponse, WebPageResponse, WebPageUpdate, WebTheme
from api.v2.schemas.web import (
WebPageVariantCreate,
WebPageVariantSummary,
WebPageVariantUpdate,
WebPageVariantsResponse,
WebUploadResponse,
)
from database.models import (
WebBlock,
WebCustomElementBuild,
WebFlow,
WebFlowEvent,
WebPage,
WebPageVariant,
WebPageVariantBlock,
WebTheme as WebThemeModel,
)
from logger import logger
UPLOAD_DIR = Path("static/web_uploads")
ALLOWED_EXTENSIONS = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".mp4", ".webm"})
MAX_FILE_SIZE = 100 * 1024 * 1024
_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9\-]*$")
EXTENSION_CONTENT_TYPES: dict[str, frozenset[str]] = {
".png": frozenset({"image/png"}),
".jpg": frozenset({"image/jpeg"}),
".jpeg": frozenset({"image/jpeg"}),
".gif": frozenset({"image/gif"}),
".webp": frozenset({"image/webp"}),
".svg": frozenset({"image/svg+xml", "text/xml", "application/xml", "text/plain"}),
".mp4": frozenset({"video/mp4"}),
".webm": frozenset({"video/webm"}),
}
def _sanitize_svg(data: bytes) -> bytes:
import re as _re
text = data.decode("utf-8", errors="replace")
text = _re.sub(r"<script[^>]*>.*?</script>", "", text, flags=_re.DOTALL | _re.IGNORECASE)
text = _re.sub(r"<style[^>]*>.*?</style>", "", text, flags=_re.DOTALL | _re.IGNORECASE)
text = _re.sub(r"\bon\w+\s*=\s*[\"'][^\"']*[\"']", "", text, flags=_re.IGNORECASE)
text = _re.sub(r"\bon\w+\s*=\s*\S+", "", text, flags=_re.IGNORECASE)
text = _re.sub(r"(?:href|xlink:href)\s*=\s*[\"']\s*javascript:[^\"']*[\"']", "", text, flags=_re.IGNORECASE)
text = _re.sub(r"(?:href|xlink:href)\s*=\s*[\"']\s*data:\s*text/html[^\"']*[\"']", "", text, flags=_re.IGNORECASE)
text = _re.sub(r"(?:href|xlink:href)\s*=\s*[\"']\s*vbscript:[^\"']*[\"']", "", text, flags=_re.IGNORECASE)
text = _re.sub(r"<foreignObject[^>]*>.*?</foreignObject>", "", text, flags=_re.DOTALL | _re.IGNORECASE)
text = _re.sub(r"<iframe[^>]*>.*?</iframe>", "", text, flags=_re.DOTALL | _re.IGNORECASE)
text = _re.sub(r"<embed[^>]*>", "", text, flags=_re.IGNORECASE)
text = _re.sub(r"<object[^>]*>.*?</object>", "", text, flags=_re.DOTALL | _re.IGNORECASE)
return text.encode("utf-8")
router = APIRouter(tags=["Web"])
@@ -22,7 +74,47 @@ class WebPagesListResponse(BaseModel):
slugs: list[str]
KNOWN_PAGE_SLUGS = ["landing", "tariffs", "faq", "login", "dashboard"]
KNOWN_PAGE_SLUGS = [
"landing",
"tariffs",
"faq",
"login",
"dashboard",
"checkout",
"gift-entry",
"referral-entry",
"partner-entry",
"payment-success",
"payment-failure",
"dashboard-keys",
"dashboard-profile",
"dashboard-instructions",
"dashboard-referrals",
]
DEFAULT_VARIANT_KEY = "default"
DEFAULT_VARIANT_NAME = "Основной"
def _normalize_variant_key(value: str | None) -> str:
raw = (value or "").strip().lower()
normalized = re.sub(r"[^a-z0-9]+", "-", raw).strip("-")
if not normalized:
return DEFAULT_VARIANT_KEY
return normalized[:64].strip("-") or DEFAULT_VARIANT_KEY
def _normalize_variant_name(value: str | None, fallback: str) -> str:
name = (value or "").strip()
return name[:255] if name else fallback
def _variant_summary(row: WebPageVariant) -> WebPageVariantSummary:
return WebPageVariantSummary(
key=row.variant_key,
name=row.name or row.variant_key,
is_active=bool(row.is_active),
)
@router.get("/api/web/pages", response_model=WebPagesListResponse)
@@ -46,69 +138,296 @@ async def get_or_create_page(session: AsyncSession, slug: str) -> WebPage:
return page
@router.get("/api/web/pages/{slug}", response_model=WebPageResponse)
async def get_web_page(
slug: str,
session: AsyncSession = Depends(get_session),
):
await get_or_create_page(session, slug)
async def _list_variants(session: AsyncSession, slug: str) -> list[WebPageVariant]:
result = await session.execute(
select(WebPageVariant)
.where(WebPageVariant.page_slug == slug)
.order_by(WebPageVariant.is_active.desc(), WebPageVariant.created_at, WebPageVariant.variant_key)
)
return list(result.scalars().all())
async def _get_theme_tokens_for_legacy_page(session: AsyncSession, slug: str) -> dict:
theme_result = await session.execute(select(WebThemeModel).where(WebThemeModel.page_slug == slug))
theme_row = theme_result.scalar_one_or_none()
return dict(theme_row.tokens or {}) if theme_row else {}
async def _get_legacy_blocks(session: AsyncSession, slug: str) -> list[WebBlock]:
blocks_result = await session.execute(
select(WebBlock).where(WebBlock.page_slug == slug).order_by(WebBlock.order, WebBlock.id)
)
blocks = [WebBlockResponse.model_validate(b) for b in blocks_result.scalars().all()]
return list(blocks_result.scalars().all())
theme_result = await session.execute(select(WebThemeModel).where(WebThemeModel.page_slug == slug))
theme_row = theme_result.scalar_one_or_none()
theme = WebTheme(tokens=theme_row.tokens) if theme_row else None
return WebPageResponse(slug=slug, blocks=blocks, theme=theme)
async def _ensure_page_variants(session: AsyncSession, slug: str) -> list[WebPageVariant]:
await get_or_create_page(session, slug)
variants = await _list_variants(session, slug)
if variants:
if not any(variant.is_active for variant in variants):
variants[0].is_active = True
await session.flush()
variants = await _list_variants(session, slug)
return variants
legacy_blocks = await _get_legacy_blocks(session, slug)
theme_tokens = await _get_theme_tokens_for_legacy_page(session, slug)
variant = WebPageVariant(
page_slug=slug,
variant_key=DEFAULT_VARIANT_KEY,
name=DEFAULT_VARIANT_NAME,
is_active=True,
theme_tokens=theme_tokens,
)
session.add(variant)
await session.flush()
for legacy_block in legacy_blocks:
session.add(
WebPageVariantBlock(
variant_id=variant.id,
order=legacy_block.order,
type=legacy_block.type,
data=legacy_block.data,
)
)
await session.flush()
return await _list_variants(session, slug)
async def _resolve_variant(
session: AsyncSession,
slug: str,
variant_key: str | None,
) -> tuple[WebPageVariant, list[WebPageVariant]]:
variants = await _ensure_page_variants(session, slug)
desired_key = _normalize_variant_key(variant_key) if variant_key else ""
current = None
if desired_key:
current = next((variant for variant in variants if variant.variant_key == desired_key), None)
if current is None:
raise HTTPException(404, "Вариант страницы не найден")
else:
current = next((variant for variant in variants if variant.is_active), variants[0])
return current, variants
async def _get_variant_blocks(session: AsyncSession, variant_id: str) -> list[WebBlockResponse]:
blocks_result = await session.execute(
select(WebPageVariantBlock)
.where(WebPageVariantBlock.variant_id == variant_id)
.order_by(WebPageVariantBlock.order, WebPageVariantBlock.id)
)
return [WebBlockResponse.model_validate(block) for block in blocks_result.scalars().all()]
async def _build_page_response(
session: AsyncSession,
slug: str,
current: WebPageVariant,
variants: list[WebPageVariant] | None = None,
) -> WebPageResponse:
current_variants = variants or await _list_variants(session, slug)
active = next((variant for variant in current_variants if variant.is_active), current)
blocks = await _get_variant_blocks(session, current.id)
theme = WebTheme(tokens=dict(current.theme_tokens or {}))
return WebPageResponse(
slug=slug,
blocks=blocks,
theme=theme,
variant_key=current.variant_key,
active_variant_key=active.variant_key,
variants=[_variant_summary(variant) for variant in current_variants],
)
async def _set_active_variant(session: AsyncSession, slug: str, variant_key: str) -> list[WebPageVariant]:
variants = await _list_variants(session, slug)
matched = False
for variant in variants:
is_target = variant.variant_key == variant_key
variant.is_active = is_target
matched = matched or is_target
if not matched:
raise HTTPException(404, "Вариант страницы не найден")
await session.flush()
return await _list_variants(session, slug)
def _generate_variant_key(existing_keys: set[str], requested_key: str | None, requested_name: str | None) -> str:
base = _normalize_variant_key(requested_key or requested_name)
if not base:
base = DEFAULT_VARIANT_KEY
if base not in existing_keys:
return base
suffix = 2
while True:
candidate = f"{base}-{suffix}"
if candidate not in existing_keys:
return candidate[:64]
suffix += 1
@router.get("/api/web/pages/{slug}", response_model=WebPageResponse)
async def get_web_page(
slug: str,
variant: str | None = Query(default=None),
session: AsyncSession = Depends(get_session),
):
if not slug or len(slug) > 64 or not _SLUG_RE.match(slug):
raise HTTPException(400, "Некорректный slug страницы")
current, variants = await _resolve_variant(session, slug, variant)
return await _build_page_response(session, slug, current, variants)
@router.put("/api/web/pages/{slug}", response_model=WebPageResponse)
async def update_web_page(
slug: str,
body: WebPageUpdate,
variant: str | None = Query(default=None),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_admin),
):
await get_or_create_page(session, slug)
await session.execute(delete(WebBlock).where(WebBlock.page_slug == slug))
current, _ = await _resolve_variant(session, slug, variant)
await session.execute(delete(WebPageVariantBlock).where(WebPageVariantBlock.variant_id == current.id))
for block in body.blocks:
session.add(
WebBlock(
page_slug=slug,
WebPageVariantBlock(
variant_id=current.id,
order=block.order,
type=block.type,
data=block.data,
)
)
theme_row = None
if body.theme is not None:
result = await session.execute(select(WebThemeModel).where(WebThemeModel.page_slug == slug))
theme_row = result.scalar_one_or_none()
if theme_row is None:
theme_row = WebThemeModel(page_slug=slug, tokens=body.theme.tokens)
session.add(theme_row)
else:
theme_row.tokens = body.theme.tokens
current.theme_tokens = body.theme.tokens
await session.flush()
refreshed_variants = await _list_variants(session, slug)
refreshed_current = next((item for item in refreshed_variants if item.id == current.id), current)
return await _build_page_response(session, slug, refreshed_current, refreshed_variants)
blocks_result = await session.execute(
select(WebBlock).where(WebBlock.page_slug == slug).order_by(WebBlock.order, WebBlock.id)
@router.get("/api/web/pages/{slug}/variants", response_model=WebPageVariantsResponse)
async def get_web_page_variants(
slug: str,
variant: str | None = Query(default=None),
session: AsyncSession = Depends(get_session),
):
current, variants = await _resolve_variant(session, slug, variant)
active = next((item for item in variants if item.is_active), current)
return WebPageVariantsResponse(
slug=slug,
active_variant_key=active.variant_key,
current_variant_key=current.variant_key,
variants=[_variant_summary(item) for item in variants],
)
blocks = [WebBlockResponse.model_validate(b) for b in blocks_result.scalars().all()]
if theme_row is None:
theme_result = await session.execute(select(WebThemeModel).where(WebThemeModel.page_slug == slug))
theme_row = theme_result.scalar_one_or_none()
theme = WebTheme(tokens=theme_row.tokens) if theme_row else None
return WebPageResponse(slug=slug, blocks=blocks, theme=theme)
@router.post("/api/web/pages/{slug}/variants", response_model=WebPageVariantsResponse)
async def create_web_page_variant(
slug: str,
body: WebPageVariantCreate,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_admin),
):
source_variant, variants = await _resolve_variant(session, slug, body.from_variant_key)
existing_keys = {variant.variant_key for variant in variants}
variant_key = _generate_variant_key(existing_keys, body.key, body.name)
if variant_key in existing_keys:
raise HTTPException(400, "Вариант с таким ключом уже существует")
variant_name = _normalize_variant_name(body.name, f"Вариант {len(variants) + 1}")
new_variant = WebPageVariant(
page_slug=slug,
variant_key=variant_key,
name=variant_name,
is_active=False,
theme_tokens=dict(source_variant.theme_tokens or {}),
)
session.add(new_variant)
await session.flush()
source_blocks = await _get_variant_blocks(session, source_variant.id)
for block in source_blocks:
session.add(
WebPageVariantBlock(
variant_id=new_variant.id,
order=block.order,
type=block.type,
data=block.data,
)
)
await session.flush()
refreshed = await _list_variants(session, slug)
return WebPageVariantsResponse(
slug=slug,
active_variant_key=next((item.variant_key for item in refreshed if item.is_active), DEFAULT_VARIANT_KEY),
current_variant_key=new_variant.variant_key,
variants=[_variant_summary(item) for item in refreshed],
)
@router.patch("/api/web/pages/{slug}/variants/{variant_key}", response_model=WebPageVariantsResponse)
async def update_web_page_variant(
slug: str,
variant_key: str,
body: WebPageVariantUpdate,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_admin),
):
current, variants = await _resolve_variant(session, slug, variant_key)
if body.name is not None:
current.name = _normalize_variant_name(body.name, current.name or current.variant_key)
if body.make_active is True:
variants = await _set_active_variant(session, slug, current.variant_key)
current = next((item for item in variants if item.variant_key == current.variant_key), current)
else:
await session.flush()
variants = await _list_variants(session, slug)
active = next((item for item in variants if item.is_active), current)
return WebPageVariantsResponse(
slug=slug,
active_variant_key=active.variant_key,
current_variant_key=current.variant_key,
variants=[_variant_summary(item) for item in variants],
)
@router.delete("/api/web/pages/{slug}/variants/{variant_key}", response_model=WebPageVariantsResponse)
async def delete_web_page_variant(
slug: str,
variant_key: str,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_admin),
):
current, variants = await _resolve_variant(session, slug, variant_key)
if len(variants) <= 1:
raise HTTPException(400, "Нельзя удалить единственный вариант страницы")
replacement = next((item for item in variants if item.variant_key != current.variant_key), None)
await session.execute(delete(WebPageVariant).where(WebPageVariant.id == current.id))
await session.flush()
if current.is_active and replacement is not None:
replacement_variants = await _set_active_variant(session, slug, replacement.variant_key)
else:
replacement_variants = await _list_variants(session, slug)
current_variant_key = replacement.variant_key if replacement is not None else DEFAULT_VARIANT_KEY
active_variant_key = next(
(item.variant_key for item in replacement_variants if item.is_active),
current_variant_key,
)
return WebPageVariantsResponse(
slug=slug,
active_variant_key=active_variant_key,
current_variant_key=current_variant_key,
variants=[_variant_summary(item) for item in replacement_variants],
)
@router.post("/api/web/upload", response_model=WebUploadResponse)
@@ -125,18 +444,271 @@ async def upload_media(
400,
f"Разрешены только: {', '.join(sorted(ALLOWED_EXTENSIONS))}",
)
if file.content_type:
allowed_types = EXTENSION_CONTENT_TYPES.get(ext)
if allowed_types and file.content_type.lower() not in allowed_types:
raise HTTPException(
400,
f"Тип файла ({file.content_type}) не соответствует расширению ({ext})",
)
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
chunks: list[bytes] = []
size = 0
for chunk in file.file:
size += len(chunk)
if size > MAX_FILE_SIZE:
raise HTTPException(400, f"Размер файла не более {MAX_FILE_SIZE // (1024*1024)} МБ")
await file.seek(0)
raise HTTPException(400, f"Размер файла не более {MAX_FILE_SIZE // (1024 * 1024)} МБ")
chunks.append(chunk)
name = f"{uuid.uuid4().hex}{ext}"
path = UPLOAD_DIR / name
file_data = b"".join(chunks)
if ext == ".svg":
file_data = _sanitize_svg(file_data)
with open(path, "wb") as f:
while chunk := await file.read(64 * 1024):
f.write(chunk)
f.write(file_data)
url = f"/api/web/uploads/{name}"
logger.info(
"[WebUpload] admin={} file={} -> {} ({} bytes)",
identity.id,
file.filename,
name,
len(file_data),
)
return WebUploadResponse(url=url)
# ── Custom Element Builds ──
class CustomElementBuildCreate(BaseModel):
label: str = ""
slug: str = ""
runtime: str = "react-component"
source_kind: str = "inline-code"
source_value: str = ""
export_name: str = "default"
props_schema_text: str = ""
sample_props_text: str = ""
events_text: str = ""
notes: str = ""
class CustomElementBuildUpdate(BaseModel):
status: str | None = None
summary: str | None = None
next_steps: list[str] | None = None
artifact: dict | None = None
upload_meta: dict | None = None
worker_id: str | None = None
def _build_to_dict(b: WebCustomElementBuild) -> dict:
return {
"id": b.id,
"label": b.label,
"slug": b.slug,
"runtime": b.runtime,
"sourceKind": b.source_kind,
"sourceValue": b.source_value,
"exportName": b.export_name,
"propsSchemaText": b.props_schema_text,
"samplePropsText": b.sample_props_text,
"eventsText": b.events_text,
"notes": b.notes,
"status": b.status,
"summary": b.summary,
"nextSteps": b.next_steps or [],
"artifact": b.artifact,
"upload": b.upload_meta,
"workerId": b.worker_id,
"workerClaimedAt": b.worker_claimed_at.isoformat() if b.worker_claimed_at else None,
"completedAt": b.completed_at.isoformat() if b.completed_at else None,
"createdAt": b.created_at.isoformat() if b.created_at else None,
"updatedAt": b.updated_at.isoformat() if b.updated_at else None,
}
@router.get("/custom-element-builds")
async def list_custom_element_builds(
session: AsyncSession = Depends(get_session),
_identity=Depends(verify_identity_admin),
):
result = await session.execute(
select(WebCustomElementBuild).order_by(WebCustomElementBuild.created_at.desc())
)
builds = result.scalars().all()
return [_build_to_dict(b) for b in builds]
@router.post("/custom-element-builds")
async def create_custom_element_build(
body: CustomElementBuildCreate,
session: AsyncSession = Depends(get_session),
_identity=Depends(verify_identity_admin),
):
build = WebCustomElementBuild(
id=str(uuid.uuid4()),
label=body.label,
slug=body.slug,
runtime=body.runtime,
source_kind=body.source_kind,
source_value=body.source_value,
export_name=body.export_name,
props_schema_text=body.props_schema_text,
sample_props_text=body.sample_props_text,
events_text=body.events_text,
notes=body.notes,
status="queued",
)
session.add(build)
return _build_to_dict(build)
@router.get("/custom-element-builds/{build_id}")
async def get_custom_element_build(
build_id: str,
session: AsyncSession = Depends(get_session),
_identity=Depends(verify_identity_admin),
):
build = await session.get(WebCustomElementBuild, build_id)
if not build:
raise HTTPException(404, "Build not found")
return _build_to_dict(build)
@router.patch("/custom-element-builds/{build_id}")
async def update_custom_element_build(
build_id: str,
body: CustomElementBuildUpdate,
session: AsyncSession = Depends(get_session),
_identity=Depends(verify_identity_admin),
):
build = await session.get(WebCustomElementBuild, build_id)
if not build:
raise HTTPException(404, "Build not found")
if body.status is not None:
build.status = body.status
if body.summary is not None:
build.summary = body.summary
if body.next_steps is not None:
build.next_steps = body.next_steps
if body.artifact is not None:
build.artifact = body.artifact
if body.upload_meta is not None:
build.upload_meta = body.upload_meta
if body.worker_id is not None:
build.worker_id = body.worker_id
return _build_to_dict(build)
@router.delete("/custom-element-builds/{build_id}")
async def delete_custom_element_build(
build_id: str,
session: AsyncSession = Depends(get_session),
_identity=Depends(verify_identity_admin),
):
build = await session.get(WebCustomElementBuild, build_id)
if not build:
raise HTTPException(404, "Build not found")
await session.delete(build)
return {"ok": True}
# ── Flow Analytics ──
class FlowEventBatch(BaseModel):
events: list[dict]
@router.post("/analytics/flow-events")
async def ingest_flow_events(
body: FlowEventBatch,
request: Request,
session: AsyncSession = Depends(get_session),
):
try:
from core.redis_cache import cache_incr_checked
from api.v2.routes.auth._fallback_limiter import check_and_increment
ip = (request.client.host if request.client else "") or "unknown"
count, redis_ok = await cache_incr_checked(f"analytics_rate:{ip}", 60)
if not redis_ok:
count = check_and_increment(f"analytics_rate:{ip}", 60, 60)
if count > 60:
raise HTTPException(status_code=429, detail="Too many events")
except HTTPException:
raise
except Exception:
pass
created = 0
for raw in body.events[:100]:
flow_id = str(raw.get("flowId", ""))
node_id = str(raw.get("nodeId", ""))
event_type = str(raw.get("eventType", ""))
if not flow_id or not node_id or not event_type:
continue
ev = WebFlowEvent(
id=str(uuid.uuid4()),
flow_id=flow_id,
node_id=node_id,
node_type=str(raw.get("nodeType", "")),
event_type=event_type,
ab_variant=raw.get("abVariant") or None,
device=raw.get("device") or None,
locale=raw.get("locale") or None,
authenticated=raw.get("authenticated"),
event_metadata=raw.get("collectedDataSnapshot") or None,
)
session.add(ev)
created += 1
return {"ingested": created}
@router.get("/analytics/flow-funnel/{flow_id}")
async def get_flow_funnel(
flow_id: str,
days: int = Query(default=30, ge=1, le=365),
session: AsyncSession = Depends(get_session),
_identity=Depends(verify_identity_admin),
):
since = datetime.now(timezone.utc) - timedelta(days=days)
rows = (
await session.execute(
select(
WebFlowEvent.node_id,
WebFlowEvent.node_type,
WebFlowEvent.event_type,
func.count().label("cnt"),
)
.where(WebFlowEvent.flow_id == flow_id)
.where(WebFlowEvent.created_at >= since)
.group_by(WebFlowEvent.node_id, WebFlowEvent.node_type, WebFlowEvent.event_type)
)
).all()
nodes: dict[str, dict] = {}
for node_id, node_type, event_type, cnt in rows:
if node_id not in nodes:
nodes[node_id] = {"nodeId": node_id, "nodeType": node_type, "entered": 0, "exited": 0, "completed": 0}
if event_type == "flow_step_entered":
nodes[node_id]["entered"] = cnt
elif event_type == "flow_step_exited":
nodes[node_id]["exited"] = cnt
elif event_type == "flow_completed":
nodes[node_id]["completed"] = cnt
flow = await session.get(WebFlow, flow_id)
if flow and flow.nodes:
node_order = {n["id"]: i for i, n in enumerate(flow.nodes) if isinstance(n, dict)}
else:
node_order = {}
funnel = sorted(nodes.values(), key=lambda n: node_order.get(n["nodeId"], 999))
for i, node in enumerate(funnel):
prev_entered = funnel[i - 1]["entered"] if i > 0 else node["entered"]
node["dropOff"] = round(
(1 - node["entered"] / prev_entered) * 100, 1
) if prev_entered > 0 else 0
return {"flowId": flow_id, "days": days, "funnel": funnel}
+10 -1
View File
@@ -28,4 +28,13 @@ from api.v1.schemas import (
)
from api.v1.schemas.keys import KeyBase, KeyCreateRequest, KeyUpdate
from api.v1.schemas.settings import SettingResponse, SettingUpsert
from api.v2.schemas.web import WebBlockResponse, WebTheme, WebPageResponse, WebPageUpdate
from api.v2.schemas.web import (
WebBlockResponse,
WebTheme,
WebPageResponse,
WebPageUpdate,
WebPageVariantCreate,
WebPageVariantSummary,
WebPageVariantUpdate,
WebPageVariantsResponse,
)
+55
View File
@@ -0,0 +1,55 @@
from __future__ import annotations
from typing import Any
from pydantic import BaseModel
class EdgeConditionSchema(BaseModel):
field: str
operator: str
value: Any = None
class FlowEdgeSchema(BaseModel):
id: str
source: str
target: str
condition: EdgeConditionSchema | None = None
label: str | None = None
priority: int | None = None
class FlowNodeSchema(BaseModel):
id: str
type: str
label: str
label_en: str | None = None
enabled: bool = True
page_slug: str | None = None
config: dict = {}
position: dict
class FlowResponse(BaseModel):
id: str
name: str
nodes: list[FlowNodeSchema]
edges: list[FlowEdgeSchema]
entry_node_id: str | None
version: int
class FlowUpdate(BaseModel):
name: str | None = None
nodes: list[FlowNodeSchema]
edges: list[FlowEdgeSchema]
entry_node_id: str | None = None
class FlowCreate(BaseModel):
id: str
name: str
nodes: list[FlowNodeSchema] = []
edges: list[FlowEdgeSchema] = []
entry_node_id: str | None = None
+36 -2
View File
@@ -13,6 +13,8 @@ class IdentityResponse(BaseModel):
email: str | None
tg_id: int | None
is_admin: bool = False
email_verified: bool = False
password_set: bool = False
created_at: datetime | None
updated_at: datetime | None
@@ -23,11 +25,12 @@ class IdentityResponse(BaseModel):
class RegisterByEmailRequest(BaseModel):
email: str = Field(..., min_length=1)
password: str = Field(..., min_length=8, description="Пароль (минимум 8 символов)")
referral_code: str | None = Field(None, min_length=1)
turnstile_token: str | None = Field(default=None, description="Cloudflare Turnstile CAPTCHA token")
class RegisterResponse(BaseModel):
identity_id: str
token: str
class LoginRequest(BaseModel):
@@ -35,13 +38,28 @@ class LoginRequest(BaseModel):
password: str = Field(...)
class SetPasswordRequest(BaseModel):
password: str = Field(..., min_length=8, description="Новый пароль (минимум 8 символов)")
password_confirm: str = Field(..., min_length=8)
class ChangePasswordRequest(BaseModel):
current_password: str = Field(...)
password: str = Field(..., min_length=8, description="Новый пароль (минимум 8 символов)")
password_confirm: str = Field(..., min_length=8)
class LoginResponse(BaseModel):
identity_id: str
token: str
class SendLoginCodeRequest(BaseModel):
email: str = Field(..., min_length=1)
allow_register: bool = Field(
default=False,
description="Если true и email новый — создать идентичность и отправить код (гостевой вход с сайта)",
)
turnstile_token: str | None = Field(default=None, description="Cloudflare Turnstile CAPTCHA token")
class LoginByCodeRequest(BaseModel):
@@ -49,6 +67,13 @@ class LoginByCodeRequest(BaseModel):
code: str = Field(..., min_length=1)
class ConfirmPasswordResetRequest(BaseModel):
email: str = Field(..., min_length=1)
code: str = Field(..., min_length=1)
password: str = Field(..., min_length=8)
password_confirm: str = Field(..., min_length=8)
class LoginTelegramRequest(BaseModel):
"""Данные от Telegram Login Widget (кнопка «Войти через Telegram»)."""
@@ -77,5 +102,14 @@ class IdentityAttachEmail(BaseModel):
email: str = Field(..., min_length=1)
class LinkEmailSendCodeRequest(BaseModel):
email: str = Field(..., min_length=1)
class LinkEmailConfirmRequest(BaseModel):
email: str = Field(..., min_length=1)
code: str = Field(..., min_length=1, max_length=16)
class IdentityAttachTelegram(BaseModel):
tg_id: int = Field(...)
+8
View File
@@ -22,3 +22,11 @@ class PaymentLinkCreateResponse(BaseModel):
payment_id: str | None = None
payment_url: str | None = None
error: str | None = None
class PaymentLinkStatusResponse(BaseModel):
success: bool
payment_id: str
status: str | None = None
completed: bool = False
paid: bool = False
+4 -1
View File
@@ -17,4 +17,7 @@ class TariffPublic(BaseModel):
device_limit: int | None
subgroup_title: str | None
sort_order: int | None
vless: bool = False
vless: bool = False
configurable: bool = False
device_options: list[int] | None = None
traffic_options_gb: list[int] | None = None
+71 -1
View File
@@ -1,6 +1,9 @@
import json
from typing import Any
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, model_validator
_MAX_BLOCK_DATA_SIZE = 256 * 1024
class WebBlockBase(BaseModel):
@@ -8,6 +11,12 @@ class WebBlockBase(BaseModel):
order: int
data: dict[str, Any]
@model_validator(mode="after")
def _check_data_size(self) -> "WebBlockBase":
if len(json.dumps(self.data, ensure_ascii=False)) > _MAX_BLOCK_DATA_SIZE:
raise ValueError(f"Размер data блока не должен превышать {_MAX_BLOCK_DATA_SIZE // 1024} КБ")
return self
class WebBlockResponse(WebBlockBase):
id: str
@@ -20,10 +29,19 @@ class WebTheme(BaseModel):
tokens: dict[str, Any]
class WebPageVariantSummary(BaseModel):
key: str = Field(..., max_length=64)
name: str = Field(..., max_length=255)
is_active: bool = False
class WebPageResponse(BaseModel):
slug: str
blocks: list[WebBlockResponse]
theme: WebTheme | None = None
variant_key: str = "default"
active_variant_key: str = "default"
variants: list[WebPageVariantSummary] = Field(default_factory=list)
class WebPageUpdate(BaseModel):
@@ -31,6 +49,58 @@ class WebPageUpdate(BaseModel):
theme: WebTheme | None = None
class WebPageVariantCreate(BaseModel):
key: str | None = Field(default=None, max_length=64)
name: str | None = Field(default=None, max_length=255)
from_variant_key: str | None = Field(default=None, max_length=64)
class WebPageVariantUpdate(BaseModel):
name: str | None = Field(default=None, max_length=255)
make_active: bool | None = None
class WebPageVariantsResponse(BaseModel):
slug: str
active_variant_key: str = "default"
current_variant_key: str = "default"
variants: list[WebPageVariantSummary] = Field(default_factory=list)
class WebUploadResponse(BaseModel):
url: str
class FlowStepConfig(BaseModel):
provider_ids: list[str] | None = None
tariff_group_code: str | None = None
tariff_ids: list[int] | None = None
display_mode: str | None = None
skippable: bool = False
auto_advance_if_single: bool = False
class FlowStepSchema(BaseModel):
id: str = Field(..., max_length=64)
type: str = Field(..., max_length=32)
label: str = Field(..., max_length=255)
label_en: str | None = Field(default=None, max_length=255)
enabled: bool = True
page_slug: str | None = Field(default=None, max_length=64)
config: FlowStepConfig = Field(default_factory=FlowStepConfig)
class FlowDefinitionSchema(BaseModel):
id: str = Field(..., max_length=64)
name: str = Field(..., max_length=255)
steps: list[FlowStepSchema] = Field(default_factory=list)
version: int = 1
class FlowDefinitionResponse(FlowDefinitionSchema):
pass
class FlowDefinitionUpdate(BaseModel):
name: str | None = Field(default=None, max_length=255)
steps: list[FlowStepSchema] = Field(default_factory=list)
+425
View File
@@ -0,0 +1,425 @@
from pydantic import BaseModel, Field
class AccountSummaryResponse(BaseModel):
identity_id: str
email: str | None = None
tg_id: int | None = None
linked_telegram: bool = False
referral_code: str = ""
balance: float = 0.0
trial_status: int = 0
keys_total: int = 0
referrals_total: int = 0
referrals_active: int = 0
referral_bonus_total: float = 0.0
gifts_sent: int = 0
gifts_claimed: int = 0
coupons_used: int = 0
partner_enabled: bool = False
partner_code: str = ""
partner_balance: float = 0.0
partner_percent: float = 0.0
partner_percent_custom: bool = False
partner_referred_total: int = 0
partner_payout_method: str | None = None
unread_notifications: int = 0
class AccountKeyActionsAvailability(BaseModel):
can_connect_device: bool = False
can_connect_router: bool = False
can_connect_tv: bool = False
can_renew: bool = False
can_addons: bool = False
can_reset_hwid: bool = False
can_qr: bool = False
can_delete: bool = False
can_change_location: bool = False
class AccountKeyDetailsResponse(BaseModel):
client_id: str
email: str
alias: str | None = None
expiry_time: int = 0
is_frozen: bool = False
tariff_name: str = ""
subgroup_title: str = ""
traffic_limit_gb: int = 0
used_traffic_gb: float | None = None
device_limit: int = 0
connected_devices: int = 0
is_tariff_configurable: bool = False
addons_devices_enabled: bool = False
addons_traffic_enabled: bool = False
class AccountKeyResponse(BaseModel):
email: str
alias: str | None = None
client_id: str
tariff_id: int | None = None
server_id: str
created_at: int = 0
expiry_time: int = 0
key: str | None = None
remnawave_link: str | None = None
is_frozen: bool = False
actions: AccountKeyActionsAvailability | None = None
class AccountKeyAliasUpdateRequest(BaseModel):
alias: str = Field(..., min_length=1, max_length=10)
class AccountKeyActionResponse(BaseModel):
ok: bool = True
message: str = ""
class AccountKeyRenewRequest(BaseModel):
provider_id: str | None = None
success_url: str | None = None
failure_url: str | None = None
coupon_code: str | None = None
class AccountKeyRenewResponse(AccountKeyActionResponse):
client_id: str
tariff_id: int
charged_rub: int = 0
balance_rub: float = 0.0
base_price_rub: int = 0
discount_rub: int = 0
final_price_rub: int = 0
applied_coupon_code: str | None = None
payment_required: bool = False
required_amount_rub: int = 0
payment_id: str | None = None
payment_url: str | None = None
class AccountKeyResetHwidResponse(AccountKeyActionResponse):
total_devices: int = 0
reset_devices: int = 0
class AccountKeyQrResponse(AccountKeyActionResponse):
link: str = ""
image_data_url: str = ""
class AccountKeyLocationOptionResponse(BaseModel):
server_name: str
class AccountKeyLocationsResponse(BaseModel):
client_id: str
current_server: str = ""
locations: list[AccountKeyLocationOptionResponse] = []
class AccountKeyChangeLocationRequest(BaseModel):
server_name: str = Field(..., min_length=1)
class AccountKeyChangeLocationResponse(AccountKeyActionResponse):
client_id: str
server_id: str = ""
link: str = ""
remnawave_link: str | None = None
class AccountKeyAddonOptionResponse(BaseModel):
value: int
label: str = ""
class AccountKeyAddonsPreviewRequest(BaseModel):
selected_device_limit: int | None = None
selected_traffic_gb: int | None = None
include_device: bool | None = None
include_traffic: bool | None = None
provider_id: str | None = None
success_url: str | None = None
failure_url: str | None = None
coupon_code: str | None = None
class AccountKeyAddonsPreviewResponse(BaseModel):
client_id: str
tariff_id: int
addons_mode: str = ""
has_device_option: bool = False
has_traffic_option: bool = False
current_device_limit: int | None = None
current_traffic_gb: int | None = None
selected_device_limit: int | None = None
selected_traffic_gb: int | None = None
device_options: list[AccountKeyAddonOptionResponse] = []
traffic_options: list[AccountKeyAddonOptionResponse] = []
total_price_rub: int = 0
extra_price_rub: int = 0
discount_rub: int = 0
final_price_rub: int = 0
applied_coupon_code: str | None = None
balance_rub: float = 0.0
class AccountKeyApplyAddonsResponse(AccountKeyActionResponse):
client_id: str
tariff_id: int
total_price_rub: int = 0
extra_price_rub: int = 0
discount_rub: int = 0
final_price_rub: int = 0
applied_coupon_code: str | None = None
charged_rub: int = 0
balance_rub: float = 0.0
payment_required: bool = False
required_amount_rub: int = 0
payment_id: str | None = None
payment_url: str | None = None
class AccountKeyActionsConfigResponse(BaseModel):
renew_enabled: bool = True
delete_enabled: bool = False
qr_enabled: bool = False
hwid_reset_enabled: bool = False
country_change_enabled: bool = False
instructions_enabled: bool = False
addons_enabled: bool = False
addons_mode: str = ""
tv_connect_enabled: bool = False
class TariffConfigPriceResponse(BaseModel):
price_rub: int
class TariffPurchaseRequest(BaseModel):
tariff_id: int = Field(..., ge=1)
selected_device_limit: int | None = None
selected_traffic_gb: int | None = None
provider_id: str | None = None
success_url: str | None = None
failure_url: str | None = None
coupon_code: str | None = None
class TariffPurchaseResponse(BaseModel):
ok: bool = True
message: str = ""
key_email: str | None = None
charged_rub: int | None = None
base_price_rub: int = 0
discount_rub: int = 0
final_price_rub: int = 0
applied_coupon_code: str | None = None
payment_required: bool = False
required_amount_rub: int = 0
payment_id: str | None = None
payment_url: str | None = None
class GiftCreateRequest(BaseModel):
tariff_id: int = Field(..., ge=1)
selected_device_limit: int | None = None
selected_traffic_gb: int | None = None
provider_id: str | None = None
success_url: str | None = None
failure_url: str | None = None
class GiftCreatePreviewResponse(BaseModel):
ok: bool = True
price_rub: int = 0
balance_rub: float = 0.0
sufficient_funds: bool = True
tariff_name: str = ""
duration_days: int = 0
class GiftCreateResponse(BaseModel):
ok: bool = True
message: str = ""
gift_id: str = ""
site_gift_link: str = ""
tariff_name: str = ""
duration_days: int = 0
price_charged: int = 0
balance_rub: float = 0.0
payment_required: bool = False
required_amount_rub: int = 0
payment_id: str | None = None
payment_url: str | None = None
class GiftUsageEntry(BaseModel):
user_id: int
used_at: str | None = None
class MyGiftItem(BaseModel):
gift_id: str
tariff_name: str = ""
duration_days: int = 0
price_rub: int = 0
created_at: str | None = None
expiry_time: str | None = None
is_used: bool = False
is_unlimited: bool = False
max_usages: int | None = None
site_gift_link: str = ""
usages: list[GiftUsageEntry] = []
class MyGiftsResponse(BaseModel):
ok: bool = True
gifts: list[MyGiftItem] = []
total: int = 0
limit: int = 20
offset: int = 0
class GiftRedeemRequest(BaseModel):
gift_code: str = Field(..., min_length=1)
class GiftRedeemResponse(BaseModel):
ok: bool = True
message: str = ""
gift_id: str = ""
tariff_id: int = 0
duration_days: int = 0
class ReferralApplyRequest(BaseModel):
referrer_code: str | None = Field(None, min_length=1)
referrer_tg_id: int | None = Field(None, ge=1)
class ReferralApplyResponse(BaseModel):
ok: bool = True
message: str = ""
referrer_code: str = ""
referrer_user_id: int = 0
referrer_tg_id: int | None = None
referred_user_id: int = 0
referred_tg_id: int | None = None
class ReferralTopEntryResponse(BaseModel):
position: int
referrer_user_id: int
referrals_count: int
display_id: str
class ReferralTopResponse(BaseModel):
user_referrals_count: int = 0
user_position: int | None = None
top: list[ReferralTopEntryResponse] = []
class ReferralQrResponse(BaseModel):
ok: bool = True
link: str = ""
image_data_url: str = ""
class ReferralConditionsResponse(BaseModel):
title: str = ""
summary: str = ""
bonus_mode: str = ""
bonus_mode_label: str = ""
level_lines: list[str] = []
rules: list[str] = []
class PartnerConditionsResponse(BaseModel):
title: str = ""
summary: str = ""
bonus_mode: str = ""
bonus_mode_label: str = ""
level_lines: list[str] = []
rules: list[str] = []
examples: list[str] = []
min_payout_rub: float = 0.0
payout_methods: list[str] = []
custom_amount_enabled: bool = False
class PartnerQrResponse(BaseModel):
ok: bool = True
link: str = ""
image_data_url: str = ""
class PartnerApplyRequest(BaseModel):
partner_code: str | None = Field(None, min_length=1)
partner_tg_id: int | None = Field(None, ge=1)
class PartnerApplyResponse(BaseModel):
ok: bool = True
message: str = ""
partner_code: str = ""
partner_user_id: int = 0
partner_tg_id: int | None = None
joined_user_id: int = 0
joined_tg_id: int | None = None
class PartnerTopEntryResponse(BaseModel):
position: int
partner_user_id: int
referred_count: int
display_id: str
class PartnerTopResponse(BaseModel):
user_referred_count: int = 0
user_position: int | None = None
top: list[PartnerTopEntryResponse] = []
class CouponApplyRequest(BaseModel):
code: str = Field(..., min_length=1, max_length=128)
class CouponApplyResponse(BaseModel):
ok: bool = True
message: str = ""
coupon_code: str = ""
amount: int = 0
balance: float = 0.0
class PartnerPayoutRequestCreate(BaseModel):
amount_rub: float = Field(..., gt=0)
class PartnerPayoutRequestResponse(BaseModel):
ok: bool = True
message: str = ""
request_id: int | None = None
amount_rub: float = 0.0
status: str = "pending"
balance_rub: float = 0.0
class PartnerPayoutEntryResponse(BaseModel):
id: int
amount_rub: float = 0.0
status: str = ""
created_at: str | None = None
method: str | None = None
destination: str | None = None
class PartnerPayoutHistoryResponse(BaseModel):
total: int = 0
items: list[PartnerPayoutEntryResponse] = []
+3
View File
@@ -873,6 +873,9 @@ async def list_audit_events(
limit=min(5000, need),
)
merged = _dedupe_event_like(redis_events + db_events)
for ev in merged:
if ev.created_at is not None and ev.created_at.tzinfo is None:
ev.created_at = ev.created_at.replace(tzinfo=timezone.utc)
merged.sort(key=lambda e: (e.created_at, getattr(e, "id", 0)), reverse=True)
return merged[offset : offset + limit]
+20
View File
@@ -256,6 +256,16 @@ _HANDLER_CONTAINS: list[tuple[str, str] | tuple[str, str, str]] = [
("auth/send-login", "login"),
("auth/login-by-code", "login"),
("auth/login-telegram", "login"),
("auth/set-password", "login"),
("auth/change-password", "login"),
("auth/request-password-reset", "login"),
("auth/confirm-password-reset", "login"),
("auth/summary", "login"),
("site-config", "api_other"),
("tariffs/purchase", "pay_start"),
("tariffs/config-price", "tariff_config"),
("gifts/redeem", "key_create"),
("referrals/apply", "referral"),
]
@@ -265,6 +275,16 @@ _API_CONTAINS: list[tuple[str, str]] = [
("auth/send-login", "login"),
("auth/login-by-code", "login"),
("auth/login-telegram", "login"),
("auth/set-password", "login"),
("auth/change-password", "login"),
("auth/request-password-reset", "login"),
("auth/confirm-password-reset", "login"),
("auth/summary", "login"),
("site-config", "api_other"),
("tariffs/purchase", "pay_start"),
("tariffs/config-price", "tariff_config"),
("gifts/redeem", "key_create"),
("referrals/apply", "referral"),
("/keys/create", "key_create"),
]
+2
View File
@@ -1,5 +1,7 @@
from importlib import import_module
version = "0.5.3"
from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
+559 -4
View File
@@ -456,7 +456,7 @@ def initialize_database() -> bool:
[
VENV_PYTHON,
"-c",
"import asyncio; from database.init_db import init_db; asyncio.run(init_db())",
"import asyncio; from database.setup.init_db import init_db; asyncio.run(init_db())",
],
cwd=PROJECT_DIR,
check=True,
@@ -1100,6 +1100,558 @@ def update_from_release():
console.print(f"[red]❌ Ошибка при обновлении: {e}[/red]")
WEB_IMAGE = "ghcr.io/vladless/solo-brick:latest"
WEB_CONTAINER_NAME = "solo-brick"
WEB_DIR = os.path.join(os.path.expanduser("~"), "solo-brick")
WEB_REMOTE_ARCHIVE = "https://github.com/Vladless/Solo_bot/archive/refs/heads/dev.tar.gz"
WEB_REMOTE_SUBDIR = "web-app"
def _find_local_web_source() -> str | None:
candidates = [
os.path.join(PROJECT_DIR, "web-app"),
os.path.join(os.path.dirname(PROJECT_DIR), "web-app"),
os.path.join(os.path.expanduser("~"), "Solo_bot", "web-app"),
]
for path in candidates:
if (
os.path.isdir(path)
and os.path.isfile(os.path.join(path, "package.json"))
and os.path.isfile(os.path.join(path, "Dockerfile"))
):
return path
return None
def _copy_local_web_source(src: str, dst: str) -> bool:
subprocess.run(["rm", "-rf", dst], check=False)
if shutil.which("rsync"):
result = subprocess.run(
[
"rsync", "-a",
"--exclude=node_modules",
"--exclude=.next",
"--exclude=.git",
"--exclude=.env",
"--exclude=.env.local",
"--exclude=.env.production",
"--exclude=logs",
"--exclude=.deploy",
"--exclude=.data",
"--exclude=.claude",
f"{src}/",
f"{dst}/",
],
check=False,
)
if result.returncode != 0:
return False
else:
try:
shutil.copytree(
src,
dst,
ignore=shutil.ignore_patterns(
"node_modules", ".next", ".git", ".env", ".env.local",
".env.production", "logs", ".deploy", ".data", ".claude",
),
)
except Exception:
return False
return os.path.isfile(os.path.join(dst, "package.json"))
def _download_web_from_github(dst: str) -> bool:
import urllib.request
import tarfile
import tempfile
subprocess.run(["rm", "-rf", dst], check=False)
os.makedirs(dst, exist_ok=True)
tmp_path = ""
try:
with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as tmp:
tmp_path = tmp.name
urllib.request.urlretrieve(WEB_REMOTE_ARCHIVE, tmp_path)
with tarfile.open(tmp_path, "r:gz") as tar:
prefix_marker = f"/{WEB_REMOTE_SUBDIR}/"
extracted = 0
for member in tar.getmembers():
idx = member.name.find(prefix_marker)
if idx == -1:
continue
relative = member.name[idx + len(prefix_marker):]
if not relative:
continue
member.name = relative
tar.extract(member, dst)
extracted += 1
if extracted == 0:
return False
except Exception as e:
console.print(f"[red]❌ Ошибка загрузки архива: {e}[/red]")
return False
finally:
if tmp_path and os.path.exists(tmp_path):
try:
os.unlink(tmp_path)
except Exception:
pass
return os.path.isfile(os.path.join(dst, "package.json"))
def _prepare_web_sources(dst: str) -> bool:
local = _find_local_web_source()
if local:
console.print(f"[cyan]Найден локальный web-app: {local}[/cyan]")
if _copy_local_web_source(local, dst):
console.print("[green]✓ Локальные исходники скопированы[/green]")
return True
console.print("[yellow]Не удалось скопировать локальные исходники. Пробую загрузку из GitHub.[/yellow]")
console.print("[cyan]Загрузка web-app из публичного репозитория Vladless/Solo_bot (dev)...[/cyan]")
if _download_web_from_github(dst):
console.print("[green]✓ Исходники загружены из публичного репозитория[/green]")
return True
console.print("[red]❌ Не удалось получить исходники web-app[/red]")
return False
def _pull_web_image() -> bool:
console.print(f"[cyan]Загрузка готового образа: {WEB_IMAGE}[/cyan]")
result = subprocess.run(
["docker", "pull", WEB_IMAGE],
check=False,
)
return result.returncode == 0
def _build_web_image(src_dir: str) -> bool:
if not os.path.isfile(os.path.join(src_dir, "package.json")):
if not _prepare_web_sources(src_dir):
return False
if not os.path.isfile(os.path.join(src_dir, "Dockerfile")):
console.print("[red]❌ В исходниках нет Dockerfile[/red]")
return False
console.print("[cyan]Сборка Docker-образа (несколько минут)...[/cyan]")
result = subprocess.run(
["docker", "build", "-t", WEB_IMAGE, "."],
cwd=src_dir, check=False,
)
if result.returncode != 0:
console.print("[red]❌ Ошибка сборки. Проверьте логи выше.[/red]")
return False
return True
def _ensure_web_image(src_dir: str, force_pull: bool = False) -> bool:
if _pull_web_image():
console.print(f"[green]✓ Образ {WEB_IMAGE} получен из GHCR[/green]")
return True
console.print("[yellow]Не удалось скачать образ из GHCR. Пробую локальную сборку.[/yellow]")
return _build_web_image(src_dir)
def _check_feature(name: str) -> bool:
try:
from core.rpc import check_feature
return check_feature(name)
except Exception:
return False
def _verify_license_for_web(code: str, password: str) -> tuple[bool, str]:
try:
from core.rpc import verify_web_license
return verify_web_license(code, password)
except Exception:
return False, ""
def _ensure_docker():
"""Проверяет/устанавливает Docker."""
if shutil.which("docker"):
try:
subprocess.run(["docker", "info"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
return True
except subprocess.CalledProcessError:
console.print("[yellow]Docker установлен, но не запущен.[/yellow]")
subprocess.run(["sudo", "systemctl", "start", "docker"], check=False)
return True
console.print("[cyan]Установка Docker...[/cyan]")
try:
subprocess.run("curl -fsSL https://get.docker.com | sh", shell=True, check=True)
subprocess.run(["sudo", "systemctl", "enable", "docker"], check=False)
subprocess.run(["sudo", "systemctl", "start", "docker"], check=False)
return True
except subprocess.CalledProcessError:
console.print("[red]❌ Не удалось установить Docker.[/red]")
return False
def _ensure_nginx():
"""Проверяет/устанавливает nginx."""
if shutil.which("nginx"):
return True
console.print("[cyan]Установка nginx...[/cyan]")
try:
subprocess.run(["sudo", "apt-get", "update", "-qq"], check=True, stdout=subprocess.DEVNULL)
subprocess.run(["sudo", "apt-get", "install", "-y", "-qq", "nginx"], check=True, stdout=subprocess.DEVNULL)
subprocess.run(["sudo", "systemctl", "enable", "nginx"], check=False)
subprocess.run(["sudo", "systemctl", "start", "nginx"], check=False)
return True
except subprocess.CalledProcessError:
console.print("[yellow]Не удалось установить nginx автоматически.[/yellow]")
return False
def _setup_nginx(domain, web_port=3000):
"""Настраивает nginx reverse proxy."""
conf = f"""server {{
listen 80;
server_name {domain};
client_max_body_size 100m;
location /_next/static/ {{
proxy_pass http://127.0.0.1:{web_port};
proxy_cache_valid 200 365d;
add_header Cache-Control "public, immutable, max-age=31536000";
}}
location = /sw.js {{
proxy_pass http://127.0.0.1:{web_port};
add_header Cache-Control "no-cache";
}}
location / {{
proxy_pass http://127.0.0.1:{web_port};
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 90s;
}}
}}"""
conf_path = f"/etc/nginx/sites-available/solo-{domain}"
enabled_path = f"/etc/nginx/sites-enabled/solo-{domain}"
try:
with open("/tmp/_solo_nginx.conf", "w") as f:
f.write(conf)
subprocess.run(["sudo", "cp", "/tmp/_solo_nginx.conf", conf_path], check=True)
subprocess.run(["sudo", "ln", "-sf", conf_path, enabled_path], check=True)
subprocess.run(["sudo", "rm", "-f", "/etc/nginx/sites-enabled/default"], check=False)
subprocess.run(["sudo", "nginx", "-t"], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
subprocess.run(["sudo", "systemctl", "reload", "nginx"], check=True)
return True
except subprocess.CalledProcessError:
console.print("[yellow]Не удалось настроить nginx.[/yellow]")
return False
def _setup_ssl(domain):
"""Получает SSL сертификат через certbot."""
if not shutil.which("certbot"):
try:
subprocess.run(["sudo", "apt-get", "install", "-y", "-qq", "certbot", "python3-certbot-nginx"],
check=True, stdout=subprocess.DEVNULL)
except subprocess.CalledProcessError:
console.print("[yellow]Не удалось установить certbot.[/yellow]")
return False
try:
subprocess.run([
"sudo", "certbot", "--nginx", "-d", domain,
"--non-interactive", "--agree-tos", "--register-unsafely-without-email", "--redirect",
], check=True)
return True
except subprocess.CalledProcessError:
console.print(f"[yellow]Не удалось получить SSL. Убедитесь что {domain} указывает на этот сервер.[/yellow]")
console.print(f"[dim]Повторите вручную: sudo certbot --nginx -d {domain}[/dim]")
return False
def install_website():
"""Устанавливает веб-приложение (сайт) через Docker."""
if not _check_feature("web"):
console.print("[yellow]Эта функция недоступна в текущей версии. Обновите бота.[/yellow]")
return
console.print(
Panel(
"[white]CLI установит Docker, скачает готовый образ сайта, настроит nginx и SSL.\n"
"Бэкенд (бот) может быть на этом же сервере или на другом.[/white]",
border_style="green",
title="[bold green]Установка веб-приложения[/bold green]",
padding=(1, 2),
)
)
console.print(
Panel(
"[bold cyan]Вариант A:[/bold cyan] Бот и сайт на одном сервере\n"
" → Адрес API: http://localhost:8000 (по умолчанию)\n\n"
"[bold cyan]Вариант B:[/bold cyan] Сайт на отдельном сервере\n"
" → Адрес API: http://IP-бота:8000 (укажите IP сервера с ботом)\n"
" → На сервере бота должен быть открыт порт 8000",
border_style="dim",
title="[dim]Варианты размещения[/dim]",
padding=(1, 2),
)
)
if not safe_confirm("[bold green]Начать установку сайта?[/bold green]", default=True):
return
console.print("\n[bold][0/5] Авторизация[/bold]")
console.print("[dim]Введите логин и пароль от вашего кабинета на сайте Solo.[/dim]")
console.print("[dim]Данные используются только для проверки лицензии и нигде не сохраняются.[/dim]\n")
lc_code = safe_prompt("[cyan]Логин (Client Code)[/cyan]")
if not lc_code or not lc_code.strip():
console.print("[red]Логин обязателен.[/red]")
return
try:
import getpass
lc_pass = getpass.getpass(" Пароль: ")
except Exception:
lc_pass = safe_prompt("[cyan]Пароль[/cyan]")
if not lc_pass or not lc_pass.strip():
console.print("[red]Пароль обязателен.[/red]")
return
console.print("[dim]Проверка лицензии...[/dim]")
lc_ok, lc_msg = _verify_license_for_web(lc_code.strip(), lc_pass.strip())
lc_code = None
lc_pass = None
if not lc_ok:
console.print(f"[red]❌ {lc_msg or 'Авторизация не пройдена'}[/red]")
return
console.print("[green]✓ Авторизация пройдена[/green]")
console.print("\n[bold][1/5] Docker[/bold]")
if not _ensure_docker():
return
console.print("\n[bold][2/5] Настройки[/bold]\n")
console.print("[dim]Домен, по которому будет открываться сайт.")
console.print("DNS (A-запись) должна уже указывать на IP этого сервера.[/dim]")
domain = safe_prompt("[cyan]Домен сайта[/cyan] (например vpn.example.com)")
if not domain or not domain.strip():
console.print("[red]Домен обязателен.[/red]")
return
domain = domain.strip()
console.print("\n[dim]Адрес API вашего бота (FastAPI).")
console.print("Если бот на этом же сервере — оставьте по умолчанию.")
console.print("Если на другом — укажите полный адрес, например http://123.45.67.89:8000[/dim]")
api_url = safe_prompt("[cyan]Адрес backend API[/cyan]", default="http://localhost:8000")
console.print("\n[dim]Внутренний порт, на котором запустится сайт.")
console.print("Nginx проксирует на него запросы. Менять нужно только если порт занят.[/dim]")
web_port = safe_prompt("[cyan]Порт сайта[/cyan]", default="3000")
console.print("\n[dim]Для push-уведомлений на сайте (колокольчик).")
console.print("Генерируется командой: npx web-push generate-vapid-keys")
console.print("Если не нужны — пропустите.[/dim]")
vapid_key = safe_prompt("[cyan]VAPID Public Key[/cyan] (Enter — пропустить)", default="")
console.print("\n[dim]Cloudflare Turnstile защищает формы логина от ботов.")
console.print("Получите ключ на dash.cloudflare.com → Turnstile.")
console.print("Если не нужно — пропустите, формы будут работать без CAPTCHA.[/dim]")
turnstile_key = safe_prompt("[cyan]Turnstile Site Key[/cyan] (Enter — пропустить)", default="")
console.print("\n[dim]Username Telegram-бота (без @) для кнопки «Войти через Telegram» на сайте.")
console.print("Если не нужно — пропустите.[/dim]")
tg_bot_username = safe_prompt("[cyan]Telegram Bot Username[/cyan] (Enter — пропустить)", default="")
console.print("\n[dim]Для отправки email-кодов (логин, подтверждение, сброс пароля).")
console.print("Если не нужно — пропустите, регистрация по email+паролю будет работать без этого.[/dim]")
smtp_host = safe_prompt("[cyan]SMTP Host[/cyan] (Enter — пропустить)", default="")
smtp_user = ""
smtp_password = ""
smtp_from = ""
if smtp_host:
smtp_user = safe_prompt("[cyan]SMTP User[/cyan]", default="")
try:
import getpass
smtp_password = getpass.getpass(" SMTP Password: ")
except Exception:
smtp_password = safe_prompt("[cyan]SMTP Password[/cyan]", default="")
smtp_from = safe_prompt("[cyan]Email From[/cyan]", default=smtp_user)
setup_ssl = safe_confirm("[cyan]Установить SSL (Let's Encrypt)?[/cyan]", default=True)
site_url = f"https://{domain}" if setup_ssl else f"http://{domain}"
console.print(f"\n Домен: [green]{domain}[/green]")
console.print(f" Backend: [green]{api_url}[/green]")
console.print(f" SSL: [green]{'Да' if setup_ssl else 'Нет'}[/green]")
if not safe_confirm("\n[yellow]Всё верно?[/yellow]", default=True):
return
console.print("\n[bold][3/5] Запуск сайта[/bold]")
os.makedirs(WEB_DIR, exist_ok=True)
from urllib.parse import urlparse
parsed_api = urlparse(api_url)
api_port_from_url = ""
if parsed_api.port is not None:
api_port_from_url = str(parsed_api.port)
elif parsed_api.scheme == "https":
api_port_from_url = "443"
elif parsed_api.scheme == "http":
api_port_from_url = "80"
env_path = os.path.join(WEB_DIR, ".env")
with open(env_path, "w") as f:
f.write(f"API_URL={api_url}\n")
f.write(f"API_BASE_URL={api_url}\n")
f.write(f"NEXT_PUBLIC_API_URL={api_url}\n")
f.write(f"NEXT_PUBLIC_API_BASE_URL={api_url}\n")
f.write(f"NEXT_PUBLIC_API_PORT={api_port_from_url}\n")
f.write(f"NEXT_PUBLIC_SITE_URL={site_url}\n")
f.write(f"NEXT_PUBLIC_VAPID_PUBLIC_KEY={vapid_key}\n")
f.write(f"NEXT_PUBLIC_TURNSTILE_SITE_KEY={turnstile_key}\n")
f.write(f"NEXT_PUBLIC_LOG_LEVEL=info\n")
f.write(f"WEB_PORT={web_port}\n")
if tg_bot_username:
f.write(f"NEXT_PUBLIC_TELEGRAM_BOT_USERNAME={tg_bot_username}\n")
if smtp_host:
f.write(f"EMAIL_SMTP_HOST={smtp_host}\n")
f.write(f"EMAIL_SMTP_PORT=465\n")
f.write(f"EMAIL_SMTP_USER={smtp_user}\n")
f.write(f"EMAIL_SMTP_PASSWORD={smtp_password}\n")
f.write(f"EMAIL_FROM={smtp_from}\n")
src_dir = os.path.join(WEB_DIR, "src")
if not _ensure_web_image(src_dir):
return
compose_path = os.path.join(WEB_DIR, "docker-compose.yml")
with open(compose_path, "w") as f:
f.write(f"""name: {WEB_CONTAINER_NAME}
services:
web:
image: {WEB_IMAGE}
container_name: {WEB_CONTAINER_NAME}
ports:
- "127.0.0.1:{web_port}:3000"
env_file:
- .env
restart: unless-stopped
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
volumes:
- ./logs:/app/logs
""")
console.print("[cyan]Запуск контейнера...[/cyan]")
subprocess.run(["docker", "compose", "up", "-d"], cwd=WEB_DIR, check=True)
console.print(f"[green]✅ Контейнер запущен на порту {web_port}[/green]")
console.print("\n[bold][4/5] Nginx[/bold]")
if _ensure_nginx():
_setup_nginx(domain, int(web_port))
console.print(f"[green]✅ nginx настроен для {domain}[/green]")
console.print("\n[bold][5/5] SSL[/bold]")
if setup_ssl:
if _setup_ssl(domain):
console.print("[green]✅ SSL сертификат установлен[/green]")
else:
console.print("[dim]SSL пропущен[/dim]")
smtp_hint = ""
if not smtp_host:
smtp_hint = "\n\n[yellow]⚠ SMTP не настроен — вход по email-коду и сброс пароля не будут работать.\n Настройте позже через: меню → Управление сайтом → Изменить настройки[/yellow]"
console.print(
Panel(
f"[bold green]Сайт доступен: {site_url}[/bold green]{smtp_hint}\n\n"
f"[white]Управление:[/white]\n"
f" cd {WEB_DIR}\n"
f" docker compose logs -f [dim]— логи[/dim]\n"
f" docker compose restart [dim]— перезапуск[/dim]\n"
f" docker compose down [dim]— остановка[/dim]\n"
f" nano .env [dim]— настройки[/dim]",
border_style="green",
title="[bold green]✅ Установка завершена[/bold green]",
padding=(1, 2),
)
)
def manage_website():
"""Меню управления сайтом."""
if not _check_feature("web"):
console.print("[yellow]Эта функция недоступна в текущей версии. Обновите бота.[/yellow]")
return
if not os.path.exists(os.path.join(WEB_DIR, "docker-compose.yml")):
console.print("[yellow]Сайт не установлен.[/yellow]")
if safe_confirm("[green]Установить сейчас?[/green]", default=True):
install_website()
return
table = Table(title="Управление сайтом", title_style="bold cyan", header_style="bold blue")
table.add_column("", justify="center", style="cyan", no_wrap=True)
table.add_column("Действие", style="white")
table.add_row("1", "Показать статус")
table.add_row("2", "Показать логи")
table.add_row("3", "Перезапустить")
table.add_row("4", "Остановить")
table.add_row("5", "Обновить (пересборка + restart)")
table.add_row("6", "Изменить настройки (.env)")
table.add_row("7", "Переустановить")
table.add_row("8", "Назад")
console.print(table)
choice = safe_prompt("[bold blue]👉 Выберите действие[/bold blue]",
choices=[str(i) for i in range(1, 9)], show_choices=False)
if choice == "1":
subprocess.run(["docker", "compose", "ps"], cwd=WEB_DIR)
elif choice == "2":
subprocess.run(["docker", "compose", "logs", "--tail", "80", "-f"], cwd=WEB_DIR)
elif choice == "3":
subprocess.run(["docker", "compose", "restart"], cwd=WEB_DIR)
console.print("[green]✅ Перезапущено[/green]")
elif choice == "4":
subprocess.run(["docker", "compose", "down"], cwd=WEB_DIR)
console.print("[yellow]Сайт остановлен[/yellow]")
elif choice == "5":
src_dir = os.path.join(WEB_DIR, "src")
console.print("[cyan]Обновление образа...[/cyan]")
if not _ensure_web_image(src_dir, force_pull=True):
return
subprocess.run(["docker", "compose", "up", "-d", "--force-recreate"], cwd=WEB_DIR)
console.print("[green]✅ Обновлено[/green]")
elif choice == "6":
env_path = os.path.join(WEB_DIR, ".env")
editor = os.environ.get("EDITOR", "nano")
subprocess.run([editor, env_path])
if safe_confirm("[cyan]Перезапустить сайт с новыми настройками?[/cyan]", default=True):
subprocess.run(["docker", "compose", "restart"], cwd=WEB_DIR)
elif choice == "7":
install_website()
def show_update_menu():
if IS_ROOT_DIR:
console.print("[red]Обновление невозможно: бот находится в /root[/red]")
@@ -1123,7 +1675,7 @@ def show_update_menu():
def show_menu():
table = Table(title="Solobot CLI v0.5.0", title_style="bold magenta", header_style="bold blue")
table = Table(title="Solobot CLI v0.5.3", title_style="bold magenta", header_style="bold blue")
table.add_column("", justify="center", style="cyan", no_wrap=True)
table.add_column("Операция", style="white")
table.add_row("1", "Запустить бота (systemd)")
@@ -1135,7 +1687,8 @@ def show_menu():
table.add_row("7", "Обновить Solobot")
table.add_row("8", "Восстановить из бэкапа")
table.add_row("9", "Установить / переустановить бота")
table.add_row("10", "Выход")
table.add_row("10", "🌐 Веб-сайт (установка / управление)")
table.add_row("11", "Выход")
console.print(table)
@@ -1150,7 +1703,7 @@ def main():
show_menu()
choice = safe_prompt(
"[bold blue]👉 Введите номер действия[/bold blue]",
choices=[str(i) for i in range(1, 11)],
choices=[str(i) for i in range(1, 12)],
show_choices=False,
)
if choice == "1":
@@ -1205,6 +1758,8 @@ def main():
elif choice == "9":
install_bot()
elif choice == "10":
manage_website()
elif choice == "11":
console.print("[bold cyan]Выход из CLI. Удачного дня![/bold cyan]")
break
except KeyboardInterrupt:
+1
View File
@@ -0,0 +1 @@
+2
View File
@@ -12,6 +12,7 @@ from .settings.payments_config import PAYMENTS_CONFIG, load_payments_config, upd
from .settings.providers_order_config import PROVIDERS_ORDER, load_providers_order, update_providers_order
from .settings.runtime_sync import publish_runtime_snapshot
from .settings.tariffs_config import TARIFFS_CONFIG, load_tariffs_config, update_tariffs_config
from .settings.web_config import WEB_CONFIG, load_web_config, update_web_config
async def bootstrap() -> None:
@@ -26,6 +27,7 @@ async def bootstrap() -> None:
await load_money_config(session)
await load_management_config(session)
await load_tariffs_config(session)
await load_web_config(session)
await session.commit()
await settings_cache.load(session)
await publish_runtime_snapshot()
-41
View File
@@ -1,44 +1,3 @@
"""
Сводка по Redis: префиксы ключей и окна жизни (TTL).
Ключ/префикс │ TTL (сек) │ Назначение
──────────────────────────┼───────────┼────────────────────────────────────────
throttle_counter │ 1 │ Счётчик троттлинга (окно сброса)
throttle_notice │ 1 │ Показ уведомления «подождите»
concurrency_notice │ 5 │ Сообщение «слишком много запросов»
utm_exists │ 300 │ UTM-код уже обработан (старт)
user_middleware │ 60 │ Снапшот пользователя (debounce*2)
user_snapshot │ 30 │ Снапшот пользователя
user_exists │ 60 │ Пользователь есть в БД
balance │ 25 │ Баланс в профиле
profile_data │ 25 │ Данные профиля
key_count │ 25 │ Количество ключей
ban_status │ 60 │ Статус бана
direct_start_user_exists │ 20 │ Пользователь есть (direct start blocker)
admin_access │ 60 │ Доступ в админку (да/нет)
remna_server │ 300 │ URL сервера Remnawave
remna_profile │ 20/45 │ Профиль Remnawave (45 при ошибке)
runtime_configs │ 86400 │ Рантайм-конфиг (1 сутки)
sub_response │ 20 │ Ответ подписки (subscription)
servers │ 60 │ Список серверов
tariff │ 120 │ Тариф по ID
tariffs_cluster │ 120 │ Тарифы по кластеру
keys_list │ 25 │ Список ключей
key_details │ 45 │ Детали ключа
key_email │ 45 │ email по client_id
payment_pending │ 3600 │ Ожидающий платёж (1 ч)
audit_history │ 300 │ История действий клиента (админка)
audit:flush │ — │ Буфер аудита (список для выгрузки в БД в 00:00)
audit:flush:processing │ — │ Батч аудита, перенесённый в processing до commit в БД
audit:flush:drain_lock │ 900 │ Лок nightly/manual drain аудита
audit:user:tg:* │ 25 ч │ События по tg_id для чтения до выгрузки
audit:user:identity:* │ 25 ч │ События по identity для чтения до выгрузки
webhook_abuse_fail │ 60 │ Счётчик неудачных вебхуков по IP
webhook_abuse_block │ 300 │ Блокировка IP по злоупотреблению
При недоступности Redis: повторная попытка подключения через 5 сек
(REDIS_BACKOFF_SEC в redis_cache).
"""
UPDATE_STALE_AGE_SEC = 60
CONCURRENCY_MAX_WAIT_SEC = 300
+22
View File
@@ -2,12 +2,14 @@ import asyncio
import json
import os
import time
from importlib import import_module
from typing import Any
from config import REDIS_URL
from logger import logger
_REDIS_CLIENTS: dict[tuple[int, int], Any] = {}
_REDIS_UNAVAILABLE_UNTIL = 0.0
_REDIS_BACKOFF_SEC = 5.0
@@ -148,6 +150,22 @@ async def cache_incr(key: str, ttl_sec: float) -> int:
return 1
async def cache_incr_checked(key: str, ttl_sec: float) -> tuple[int, bool]:
"""Возвращает (value, redis_available). redis_available=False значит клиент
должен применить fallback-логику (например, in-memory limiter).
"""
client = await _get_redis()
if client is None:
return 1, False
try:
value = await client.incr(key)
if value == 1:
await client.expire(key, max(1, int(ttl_sec)))
return int(value), True
except Exception:
return 1, False
async def cache_delete_pattern(pattern: str) -> int:
client = await _get_redis()
if client is None:
@@ -266,3 +284,7 @@ async def cache_lmove_batch(source: str, destination: str, count: int) -> list[A
except Exception as exc:
logger.warning(f"[Redis] lmove_batch({source}->{destination}) не удался: {exc}")
return []
async def redis_connection_ok() -> bool:
return await _get_redis() is not None
+78
View File
@@ -0,0 +1,78 @@
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Setting
from database.settings_cache import settings_cache
from ..defaults import DEFAULT_WEB_CONFIG
from .runtime_sync import publish_runtime_config, register_runtime_config
WEB_CONFIG: dict[str, Any] = DEFAULT_WEB_CONFIG.copy()
WEB_SETTING_KEY = "WEB_CONFIG"
register_runtime_config(WEB_SETTING_KEY, WEB_CONFIG)
async def load_web_config(session: AsyncSession) -> None:
stmt = select(Setting).where(Setting.key == WEB_SETTING_KEY)
result = await session.execute(stmt)
setting = result.scalar_one_or_none()
if setting is None:
web_config = DEFAULT_WEB_CONFIG.copy()
setting = Setting(
key=WEB_SETTING_KEY,
value=web_config,
description="Конфигурация веб-сайта",
)
session.add(setting)
else:
stored = setting.value or {}
web_config = DEFAULT_WEB_CONFIG.copy()
web_config.update(stored)
setting.value = web_config
WEB_CONFIG.clear()
WEB_CONFIG.update(web_config)
await session.flush()
async def update_web_config(session: AsyncSession, new_values: dict[str, Any]) -> None:
stmt = select(Setting).where(Setting.key == WEB_SETTING_KEY)
result = await session.execute(stmt)
setting = result.scalar_one_or_none()
if setting is None:
setting = Setting(
key=WEB_SETTING_KEY,
value=new_values,
description="Конфигурация веб-сайта",
)
session.add(setting)
else:
setting.value = new_values
await session.commit()
web_config = DEFAULT_WEB_CONFIG.copy()
web_config.update(new_values)
WEB_CONFIG.clear()
WEB_CONFIG.update(web_config)
settings_cache.update(WEB_SETTING_KEY, web_config)
await publish_runtime_config(WEB_SETTING_KEY, web_config)
def get_site_url() -> str:
"""Возвращает SITE_URL из WEB_CONFIG, или из config.py как fallback."""
url = str(WEB_CONFIG.get("SITE_URL") or "").strip()
if url:
return url.rstrip("/")
from config import SITE_URL
return SITE_URL.rstrip("/") if SITE_URL else ""
def is_web_enabled() -> bool:
return bool(WEB_CONFIG.get("WEB_ENABLED", False))
+28
View File
@@ -26,6 +26,7 @@ async def scheduled_stats_report() -> None:
async def sweep_stale_payments_job() -> None:
async with async_session_maker() as session:
await cancel_expired_pending_payments(session)
await session.commit()
def scheduled_audit_drain_process_runner() -> None:
@@ -40,6 +41,33 @@ def sweep_stale_payments_process_runner() -> None:
asyncio.run(sweep_stale_payments_job())
async def cleanup_expired_gifts_job() -> None:
from datetime import datetime
from sqlalchemy import update as sa_update
from database.models import Gift
async with async_session_maker() as session:
try:
result = await session.execute(
sa_update(Gift)
.where(Gift.expiry_time < datetime.utcnow(), Gift.is_used == False)
.values(is_used=True)
)
count = result.rowcount
await session.commit()
if count:
logger.info("[GiftCleanup] Просроченных подарков помечено использованными: {}", count)
except Exception as error:
logger.error("[GiftCleanup] Ошибка очистки подарков: {}", error)
def cleanup_expired_gifts_process_runner() -> None:
asyncio.run(cleanup_expired_gifts_job())
AUDIT_DRAIN_TRIGGER = CronTrigger(hour=0, minute=0, timezone="Europe/Moscow")
DAILY_STATS_REPORT_TRIGGER = CronTrigger(hour=0, minute=1, timezone="Europe/Moscow")
STALE_PAYMENTS_SWEEP_TRIGGER = CronTrigger(minute=0, timezone="Europe/Moscow")
EXPIRED_GIFTS_CLEANUP_TRIGGER = CronTrigger(hour=3, minute=0, timezone="Europe/Moscow")
+34 -12
View File
@@ -3,6 +3,7 @@ import fcntl
import inspect
import multiprocessing
import os
import tempfile
import threading
from collections.abc import Awaitable, Callable
@@ -109,6 +110,19 @@ class PeriodicTaskManager:
self._process_lock_file = None
self._process_lock_path = "/tmp/solo_bot_periodic_manager.lock"
def _process_lock_candidates(self) -> list[str]:
candidates = [self._process_lock_path]
uid_suffix = f"solo_bot_periodic_manager_{os.getuid()}.lock"
runtime_dir = os.environ.get("XDG_RUNTIME_DIR", "").strip()
if runtime_dir:
candidates.append(os.path.join(runtime_dir, uid_suffix))
candidates.append(os.path.join(tempfile.gettempdir(), uid_suffix))
unique_candidates: list[str] = []
for candidate in candidates:
if candidate not in unique_candidates:
unique_candidates.append(candidate)
return unique_candidates
def register_loop_task(self, task_id: str, runner: LoopRunner) -> None:
self._loop_tasks[task_id] = ManagedLoopTask(task_id=task_id, runner=runner)
@@ -144,18 +158,26 @@ class PeriodicTaskManager:
def _acquire_process_lock(self) -> bool:
if self._process_lock_file is not None:
return True
lock_file = open(self._process_lock_path, "a+", encoding="utf-8")
try:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
lock_file.seek(0)
lock_file.truncate()
lock_file.write(str(os.getpid()))
lock_file.flush()
self._process_lock_file = lock_file
return True
except OSError:
lock_file.close()
return False
for candidate_path in self._process_lock_candidates():
try:
lock_file = open(candidate_path, "a+", encoding="utf-8")
except OSError as error:
logger.warning("[PeriodicManager] Не удалось открыть lock-файл {}: {}", candidate_path, error)
continue
try:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
lock_file.seek(0)
lock_file.truncate()
lock_file.write(str(os.getpid()))
lock_file.flush()
self._process_lock_file = lock_file
self._process_lock_path = candidate_path
return True
except OSError:
lock_file.close()
return False
logger.warning("[PeriodicManager] Не удалось создать lock-файл, запуск менеджера пропущен")
return False
def _release_process_lock(self) -> None:
if self._process_lock_file is None:
+17
View File
@@ -1,7 +1,10 @@
from core.tasks.cron_tasks import (
AUDIT_DRAIN_TRIGGER,
DAILY_STATS_REPORT_TRIGGER,
EXPIRED_GIFTS_CLEANUP_TRIGGER,
STALE_PAYMENTS_SWEEP_TRIGGER,
cleanup_expired_gifts_job,
cleanup_expired_gifts_process_runner,
scheduled_audit_drain,
scheduled_audit_drain_process_runner,
scheduled_stats_report,
@@ -98,4 +101,18 @@ def register_periodic_tasks() -> None:
STALE_PAYMENTS_SWEEP_TRIGGER,
)
if process_budget > 0:
periodic_task_manager.register_cron_task(
"cleanup_expired_gifts",
cleanup_expired_gifts_process_runner,
EXPIRED_GIFTS_CLEANUP_TRIGGER,
execution_mode="process",
)
else:
periodic_task_manager.register_cron_task(
"cleanup_expired_gifts",
cleanup_expired_gifts_job,
EXPIRED_GIFTS_CLEANUP_TRIGGER,
)
_TASKS_REGISTERED = True
+4 -2
View File
@@ -1,5 +1,7 @@
from aiohttp import web
from logger import logger
from core.cache_config import (
WEBHOOK_ABUSE_BLOCK_TTL_SEC,
WEBHOOK_ABUSE_FAIL_THRESHOLD,
@@ -48,5 +50,5 @@ async def record_webhook_signature_failure(ip: str) -> None:
block_key = cache_key("webhook_abuse_block", ip)
await cache_set(block_key, 1, WEBHOOK_ABUSE_BLOCK_TTL_SEC)
await cache_delete(fail_key)
except Exception:
pass
except Exception as e:
logger.warning("[WebhookAbuse] Ошибка записи fail-счётчика для IP={}: {}", ip, e)
+1 -1
View File
@@ -5,7 +5,7 @@ from .db import Base, async_session_maker, engine, reset_async_db_engine
from .gifts import *
from . import identities
from .hot_leads import *
from .init_db import *
from .setup.init_db import *
from .keys import *
from .notifications import *
from .payments import *
+2
View File
@@ -0,0 +1,2 @@
from .resolution import *
from .tg_mirror import *
+87
View File
@@ -0,0 +1,87 @@
from dataclasses import dataclass
from enum import Enum
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Identity, User
class ActorSurface(str, Enum):
TELEGRAM = "telegram"
WEB = "web"
UNKNOWN = "unknown"
@dataclass(frozen=True)
class ResolvedActor:
surface: ActorSurface
billing_user_id: int | None
telegram_chat_id: int | None
identity_id: str | None
def telegram_chat_id(user: User | None) -> int | None:
if user is None:
return None
return user.tg_id
async def resolve_user_optional(session: AsyncSession, legacy_id: int) -> User | None:
r = await session.execute(select(User).where(User.tg_id == legacy_id))
u = r.scalar_one_or_none()
if u is not None:
return u
r2 = await session.execute(select(User).where(User.id == legacy_id))
return r2.scalar_one_or_none()
async def notify_telegram_chat_id(session: AsyncSession, legacy_ref: int) -> int | None:
payer = await resolve_user_optional(session, legacy_ref)
tg = telegram_chat_id(payer)
if tg is not None:
return tg
if payer is None:
return legacy_ref
return None
async def resolve_actor_from_legacy_ref(session: AsyncSession, legacy_ref: int) -> ResolvedActor:
user = await resolve_user_optional(session, legacy_ref)
if user is None:
return ResolvedActor(
surface=ActorSurface.UNKNOWN,
billing_user_id=None,
telegram_chat_id=legacy_ref,
identity_id=None,
)
user_tg = telegram_chat_id(user)
if user_tg is not None and int(user_tg) == int(legacy_ref):
surface = ActorSurface.TELEGRAM
elif int(user.id) == int(legacy_ref):
surface = ActorSurface.WEB
elif user_tg is None:
surface = ActorSurface.WEB
else:
surface = ActorSurface.UNKNOWN
return ResolvedActor(
surface=surface,
billing_user_id=int(user.id),
telegram_chat_id=user_tg,
identity_id=user.identity_id,
)
async def resolve_actor_from_identity(session: AsyncSession, identity: Identity) -> ResolvedActor:
from database.identities import ensure_billing_user_for_identity
billing_uid = await ensure_billing_user_for_identity(session, identity)
user = await resolve_user_optional(session, billing_uid)
return ResolvedActor(
surface=ActorSurface.WEB,
billing_user_id=billing_uid,
telegram_chat_id=telegram_chat_id(user),
identity_id=identity.id,
)
+50
View File
@@ -0,0 +1,50 @@
from __future__ import annotations
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import (
BlockedUser,
CouponUsage,
Gift,
GiftUsage,
Key,
ManualBan,
Notification,
Payment,
Referral,
TemporaryData,
User,
)
def mirror_telegram_id(user: User | None) -> int | None:
if user is None:
return None
return user.tg_id
async def refresh_tg_mirrors_for_user(session: AsyncSession, user_id: int) -> None:
r = await session.execute(select(User.tg_id).where(User.id == user_id))
tg = r.scalar_one_or_none()
await session.execute(update(Key).where(Key.user_id == user_id).values(tg_id=tg))
await session.execute(update(Payment).where(Payment.user_id == user_id).values(tg_id=tg))
await session.execute(update(Notification).where(Notification.user_id == user_id).values(tg_id=tg))
await session.execute(update(GiftUsage).where(GiftUsage.user_id == user_id).values(tg_id=tg))
await session.execute(update(CouponUsage).where(CouponUsage.user_id == user_id).values(tg_id=tg))
await session.execute(update(TemporaryData).where(TemporaryData.user_id == user_id).values(tg_id=tg))
await session.execute(update(BlockedUser).where(BlockedUser.user_id == user_id).values(tg_id=tg))
await session.execute(update(ManualBan).where(ManualBan.user_id == user_id).values(tg_id=tg))
await session.execute(
update(Referral).where(Referral.referred_user_id == user_id).values(referred_tg_id=tg)
)
await session.execute(
update(Referral).where(Referral.referrer_user_id == user_id).values(referrer_tg_id=tg)
)
await session.execute(update(Gift).where(Gift.sender_user_id == user_id).values(sender_tg_id=tg))
await session.execute(
update(Gift).where(Gift.recipient_user_id == user_id).values(recipient_tg_id=tg)
)
+1 -1
View File
@@ -77,7 +77,7 @@ async def fetch_successful_payment_rows_db(
Payment.created_at,
)
stmt = (
select(Payment.payment_system, Payment.payment_id, Payment.tg_id)
select(Payment.payment_system, Payment.payment_id, Payment.user_id)
.where(
Payment.status == "success",
Payment.payment_system.notin_(PAYMENT_SYSTEMS_EXCLUDED),
+26 -10
View File
@@ -1,27 +1,43 @@
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import BlockedUser
from database.models import BlockedUser, User
from database.access.resolution import resolve_user_optional
from logger import logger
async def create_blocked_user(session: AsyncSession, tg_id: int):
stmt = insert(BlockedUser).values(tg_id=tg_id).on_conflict_do_nothing(index_elements=[BlockedUser.tg_id])
async def create_blocked_user(session: AsyncSession, legacy_user_ref: int):
u = await resolve_user_optional(session, legacy_user_ref)
if u is None:
return
stmt = (
insert(BlockedUser)
.values(user_id=u.id, tg_id=u.tg_id)
.on_conflict_do_nothing(index_elements=[BlockedUser.user_id])
)
await session.execute(stmt)
await session.commit()
async def save_blocked_user_ids(session: AsyncSession, tg_ids: list[int]) -> None:
"""Вставка списка tg_id в таблицу BlockedUser батчами по 500. Вызывать только из основного event loop."""
"""Вставка списка telegram id в таблицу blocked_users батчами по 500."""
if not tg_ids:
return
batch_size = 500
total = 0
for i in range(0, len(tg_ids), batch_size):
batch = tg_ids[i : i + batch_size]
values = [{"tg_id": tg_id} for tg_id in batch]
stmt = insert(BlockedUser).values(values).on_conflict_do_nothing(index_elements=[BlockedUser.tg_id])
res = await session.execute(select(User.id, User.tg_id).where(User.tg_id.in_(batch)))
rows = res.all()
uid_by_tg = {int(tgid): int(uid) for uid, tgid in rows if tgid is not None}
values = [
{"user_id": uid_by_tg[int(tg)], "tg_id": int(tg)}
for tg in batch
if int(tg) in uid_by_tg
]
if not values:
continue
stmt = insert(BlockedUser).values(values).on_conflict_do_nothing(index_elements=[BlockedUser.user_id])
await session.execute(stmt)
await session.commit()
total += len(batch)
logger.info(f"📝 Добавлено {total} пользователей в blocked_users")
total += len(values)
logger.info(f"📝 Добавлено до {total} пользователей в blocked_users")
+112 -65
View File
@@ -1,9 +1,9 @@
from datetime import datetime
from sqlalchemy import case, delete, func, insert, select, update
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy import case, delete, func, insert, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from database.access.resolution import resolve_user_optional
from database.models import Coupon, CouponUsage
from logger import logger
@@ -19,48 +19,42 @@ async def create_coupon(
max_discount_amount: int | None = None,
min_order_amount: int | None = None,
) -> bool:
try:
exists = await session.scalar(select(Coupon.id).where(Coupon.code == code))
if exists:
logger.warning(f"[Coupon] ⚠️ Купон с кодом {code} уже существует.")
exists = await session.scalar(select(Coupon.id).where(Coupon.code == code))
if exists:
logger.warning(f"[Coupon] ⚠️ Купон с кодом {code} уже существует.")
return False
if percent is not None:
try:
percent_value = int(percent)
except (TypeError, ValueError):
logger.warning(f"[Coupon] ⚠️ Некорректный процент для купона {code}.")
return False
if percent is not None:
try:
percent_value = int(percent)
except (TypeError, ValueError):
logger.warning(f"[Coupon] ⚠️ Некорректный процент для купона {code}.")
return False
if percent_value <= 0 or percent_value > 100:
logger.warning(f"[Coupon] ⚠️ процент должен быть в диапазоне 1..100 для купона {code}.")
return False
if percent_value <= 0 or percent_value > 100:
logger.warning(f"[Coupon] ⚠️ процент должен быть в диапазоне 1..100 для купона {code}.")
return False
if (amount or 0) > 0 or (days or 0) > 0:
logger.warning(f"[Coupon] ⚠️ Купон {code} не может одновременно иметь percent и amount/days.")
return False
if (amount or 0) > 0 or (days or 0) > 0:
logger.warning(f"[Coupon] ⚠️ Купон {code} не может одновременно иметь percent и amount/days.")
return False
await session.execute(
insert(Coupon).values(
code=code,
amount=int(amount) if amount is not None else 0,
usage_limit=usage_limit,
usage_count=0,
is_used=False,
days=days,
new_users_only=new_users_only,
percent=percent,
max_discount_amount=max_discount_amount,
min_order_amount=min_order_amount,
)
await session.execute(
insert(Coupon).values(
code=code,
amount=int(amount) if amount is not None else 0,
usage_limit=usage_limit,
usage_count=0,
is_used=False,
days=days,
new_users_only=new_users_only,
percent=percent,
max_discount_amount=max_discount_amount,
min_order_amount=min_order_amount,
)
await session.commit()
logger.info(f"[Coupon] ✅ Купон {code} успешно создан.")
return True
except SQLAlchemyError as e:
await session.rollback()
logger.error(f"[Coupon] ❌ Ошибка при создании купона {code}: {e}")
return False
)
logger.info(f"[Coupon] ✅ Купон {code} успешно создан.")
return True
async def get_coupon_by_code(session: AsyncSession, code: str) -> Coupon | None:
@@ -69,6 +63,15 @@ async def get_coupon_by_code(session: AsyncSession, code: str) -> Coupon | None:
return result.scalar_one_or_none()
async def get_coupon_by_code_ci(session: AsyncSession, code: str) -> Coupon | None:
normalized = str(code or "").strip()
if not normalized:
return None
stmt = select(Coupon).where(func.lower(Coupon.code) == normalized.lower())
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def get_all_coupons(session: AsyncSession, page: int = 1, per_page: int = 10) -> dict:
offset = (page - 1) * per_page
@@ -99,45 +102,89 @@ async def delete_coupon(session: AsyncSession, code: str) -> bool:
await session.execute(delete(CouponUsage).where(CouponUsage.coupon_id == coupon.id))
await session.delete(coupon)
await session.commit()
logger.info(f"🗑 Купон {code} удалён вместе с его использованиями")
return True
async def _coupon_usage_billing_match(session: AsyncSession, legacy_user_ref: int):
u = await resolve_user_optional(session, legacy_user_ref)
if u is not None:
opts = [CouponUsage.user_id == u.id]
if u.tg_id is not None:
opts.append(CouponUsage.tg_id == u.tg_id)
return or_(*opts)
return or_(CouponUsage.user_id == legacy_user_ref, CouponUsage.tg_id == legacy_user_ref)
async def create_coupon_usage(session: AsyncSession, coupon_id: int, user_id: int):
try:
stmt = insert(CouponUsage).values(coupon_id=coupon_id, user_id=user_id, used_at=datetime.utcnow())
await session.execute(stmt)
await session.commit()
logger.info(f"✅ Купон {coupon_id} использован пользователем {user_id}")
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при сохранении использования купона: {e}")
await session.rollback()
raise
u = await resolve_user_optional(session, user_id)
uid = u.id if u is not None else user_id
stmt = insert(CouponUsage).values(
coupon_id=coupon_id,
user_id=uid,
tg_id=u.tg_id if u is not None else None,
used_at=datetime.utcnow(),
)
await session.execute(stmt)
logger.info(f"✅ Купон {coupon_id} использован пользователем {user_id}")
async def check_coupon_usage(session: AsyncSession, coupon_id: int, user_id: int) -> bool:
stmt = select(CouponUsage).where(CouponUsage.coupon_id == coupon_id, CouponUsage.user_id == user_id)
async def check_coupon_usage(session: AsyncSession, coupon_id: int, legacy_user_ref: int) -> bool:
m = await _coupon_usage_billing_match(session, legacy_user_ref)
stmt = select(CouponUsage).where(CouponUsage.coupon_id == coupon_id).where(m)
result = await session.execute(stmt)
return result.scalar_one_or_none() is not None
async def has_any_coupon_usage(session: AsyncSession, legacy_user_ref: int) -> bool:
m = await _coupon_usage_billing_match(session, legacy_user_ref)
stmt = select(CouponUsage.coupon_id).where(m).limit(1)
result = await session.execute(stmt)
return result.first() is not None
async def update_coupon_usage_count(session: AsyncSession, coupon_id: int):
try:
await session.execute(
update(Coupon)
.where(Coupon.id == coupon_id)
.values(
usage_count=Coupon.usage_count + 1,
is_used=case((Coupon.usage_count + 1 >= Coupon.usage_limit, True), else_=False),
)
await session.execute(
update(Coupon)
.where(Coupon.id == coupon_id)
.values(
usage_count=Coupon.usage_count + 1,
is_used=case((Coupon.usage_count + 1 >= Coupon.usage_limit, True), else_=False),
)
await session.commit()
logger.info(f"🔁 Обновлён счётчик купона {coupon_id}")
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при обновлении купона {coupon_id}: {e}")
await session.rollback()
raise
)
logger.info(f"🔁 Обновлён счётчик купона {coupon_id}")
async def mark_coupon_used(session: AsyncSession, coupon_id: int, legacy_user_ref: int):
u = await resolve_user_optional(session, legacy_user_ref)
uid = u.id if u is not None else legacy_user_ref
match = [CouponUsage.user_id == int(uid)]
if u is not None and u.tg_id is not None:
match.append(CouponUsage.tg_id == int(u.tg_id))
existing = await session.execute(
select(CouponUsage).where(
CouponUsage.coupon_id == int(coupon_id),
or_(*match),
)
)
if existing.scalar_one_or_none() is not None:
return
await session.execute(
insert(CouponUsage).values(
coupon_id=coupon_id,
user_id=uid,
tg_id=u.tg_id if u is not None else None,
used_at=datetime.utcnow(),
)
)
await session.execute(
update(Coupon)
.where(Coupon.id == coupon_id)
.values(
usage_count=Coupon.usage_count + 1,
is_used=case((Coupon.usage_count + 1 >= Coupon.usage_limit, True), else_=False),
)
)
def apply_percent_coupon(price_rub: int, coupon: Coupon) -> tuple[int, int]:
+6
View File
@@ -20,6 +20,12 @@ if USE_PGBOUNCER and "+asyncpg" in DATABASE_URL:
_pool_recycle = 60 if USE_PGBOUNCER else 300
_QUERY_TIMEOUT_SEC = 30
if "+asyncpg" in _db_url:
_connect_args.setdefault("command_timeout", _QUERY_TIMEOUT_SEC)
_connect_args.setdefault("timeout", _QUERY_TIMEOUT_SEC)
def _create_engine():
return create_async_engine(
+95 -31
View File
@@ -1,17 +1,17 @@
from datetime import datetime
from sqlalchemy import insert
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy import func, insert, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Gift
from database.access.resolution import resolve_user_optional
from database.models import Gift, GiftUsage
from logger import logger
async def store_gift_link(
session: AsyncSession,
gift_id: str,
sender_tg_id: int,
sender_legacy_ref: int,
selected_months: int,
expiry_time: datetime,
gift_link: str,
@@ -21,33 +21,97 @@ async def store_gift_link(
selected_device_limit: int | None = None,
selected_traffic_gb: int | None = None,
selected_price_rub: int | None = None,
):
try:
stmt = insert(Gift).values(
) -> bool:
u = await resolve_user_optional(session, sender_legacy_ref)
if u is None:
raise ValueError(f"sender not found for gift: {sender_legacy_ref}")
stmt = insert(Gift).values(
gift_id=gift_id,
sender_user_id=u.id,
sender_tg_id=u.tg_id,
recipient_user_id=None,
selected_months=selected_months,
expiry_time=expiry_time,
gift_link=gift_link,
created_at=datetime.utcnow(),
is_used=False,
tariff_id=tariff_id,
is_unlimited=is_unlimited,
max_usages=max_usages,
selected_device_limit=selected_device_limit,
selected_traffic_gb=selected_traffic_gb,
selected_price_rub=selected_price_rub,
)
await session.execute(stmt)
logger.info(
f"🎁 Подарок {gift_id} сохранён "
f"(tariff_id={tariff_id}, max_usages={max_usages}, "
f"device={selected_device_limit}, traffic={selected_traffic_gb}, price={selected_price_rub})"
)
return True
async def get_gift_locked(session: AsyncSession, gift_id: str) -> Gift | None:
"""SELECT FOR UPDATE по gift_id — берёт row-lock для atomic redemption.
Используется в `services.gifts.redeem_gift` чтобы два параллельных запроса
на активацию одного и того же подарка не смогли обойти проверку `is_used`.
"""
result = await session.execute(select(Gift).where(Gift.gift_id == gift_id).with_for_update())
return result.scalar_one_or_none()
async def get_gift_usage(session: AsyncSession, gift_id: str, user_id: int) -> GiftUsage | None:
"""Возвращает запись об использовании подарка конкретным пользователем, если есть."""
result = await session.execute(
select(GiftUsage).where(
GiftUsage.gift_id == gift_id,
GiftUsage.user_id == user_id,
)
)
return result.scalar_one_or_none()
async def count_gift_usages(session: AsyncSession, gift_id: str) -> int:
"""Сколько раз подарок был активирован (для `is_unlimited=False` с лимитом)."""
result = await session.execute(
select(func.count()).select_from(GiftUsage).where(GiftUsage.gift_id == gift_id)
)
return int(result.scalar_one() or 0)
async def record_gift_usage(
session: AsyncSession,
gift_id: str,
user_id: int,
tg_id: int | None,
) -> None:
"""Вставляет запись о применении подарка. Композитный ключ (gift_id, user_id)."""
await session.execute(
insert(GiftUsage).values(
gift_id=gift_id,
sender_tg_id=sender_tg_id,
recipient_tg_id=None,
selected_months=selected_months,
expiry_time=expiry_time,
gift_link=gift_link,
created_at=datetime.utcnow(),
is_used=False,
tariff_id=tariff_id,
is_unlimited=is_unlimited,
max_usages=max_usages,
selected_device_limit=selected_device_limit,
selected_traffic_gb=selected_traffic_gb,
selected_price_rub=selected_price_rub,
user_id=user_id,
tg_id=tg_id,
)
await session.execute(stmt)
await session.commit()
logger.info(
f"🎁 Подарок {gift_id} сохранён "
f"(tariff_id={tariff_id}, max_usages={max_usages}, "
f"device={selected_device_limit}, traffic={selected_traffic_gb}, price={selected_price_rub})"
)
async def mark_gift_fully_redeemed(
session: AsyncSession,
gift_id: str,
recipient_user_id: int,
recipient_tg_id: int | None,
) -> None:
"""Помечает подарок как полностью использованный (is_used=True) и фиксирует получателя.
Вызывается для non-unlimited подарков, когда набрали max_usages.
"""
await session.execute(
update(Gift)
.where(Gift.gift_id == gift_id)
.values(
is_used=True,
recipient_user_id=recipient_user_id,
recipient_tg_id=recipient_tg_id,
)
return True
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при сохранении подарка {gift_id}: {e}")
await session.rollback()
raise
)
+4 -4
View File
@@ -8,17 +8,17 @@ from database.models import Key, Payment, User
async def get_hot_leads(session: AsyncSession):
now_ms = func.extract("epoch", func.now()) * 1000
sub_active = select(Key.tg_id).where(Key.expiry_time > now_ms).distinct()
sub_active = select(Key.user_id).where(Key.expiry_time > now_ms).distinct()
stmt = (
select(Payment.tg_id)
.join(User, User.tg_id == Payment.tg_id)
select(Payment.user_id)
.join(User, User.id == Payment.user_id)
.distinct()
.where(User.trial == 1)
.where(Payment.amount > 0)
.where(Payment.status == "success")
.where(Payment.payment_system.notin_(PAYMENT_SYSTEMS_EXCLUDED))
.where(~Payment.tg_id.in_(sub_active))
.where(~Payment.user_id.in_(sub_active))
)
result = await session.execute(stmt)
+208 -10
View File
@@ -3,11 +3,12 @@ import secrets
from datetime import datetime, timedelta
import bcrypt
from sqlalchemy import select
from sqlalchemy import delete, func, select, text, update
from sqlalchemy.ext.asyncio import AsyncSession
from config import API_TOKEN_TTL_DAYS
from core.executor import run_cpu, run_io
from database.access.tg_mirror import refresh_tg_mirrors_for_user
from database.models import Admin, Identity, User
@@ -57,7 +58,6 @@ async def create_identity(
await session.flush()
if tg_id:
await session.execute(User.__table__.update().where(User.tg_id == tg_id).values(identity_id=identity.id))
await session.commit()
await session.refresh(identity)
return identity
@@ -93,7 +93,6 @@ async def issue_token_for_identity(session: AsyncSession, identity: Identity) ->
token = generate_token()
identity.api_token_hash = await run_io(hash_token, token)
identity.token_issued_at = datetime.utcnow()
await session.commit()
await session.refresh(identity)
return token
@@ -116,7 +115,6 @@ async def create_identity_with_token(
identity = await create_identity(session, email=email, tg_id=tg_id)
if password:
identity.password_hash = await run_cpu(hash_password, password)
await session.commit()
await session.refresh(identity)
token = await issue_token_for_identity(session, identity)
return identity, token
@@ -146,10 +144,209 @@ async def login_by_email(session: AsyncSession, email: str, password: str) -> tu
return identity, token
async def resolve_tg_id(session: AsyncSession, identity_id: str) -> int | None:
"""По identity_id возвращает tg_id, если привязан."""
async def set_initial_password(
session: AsyncSession,
identity_id: str,
password: str,
) -> Identity | None:
identity = await get_identity_by_id(session, identity_id)
return identity.tg_id if identity else None
if not identity or identity.password_hash:
return None
identity.password_hash = await run_cpu(hash_password, password)
await session.refresh(identity)
return identity
async def set_password_for_identity(
session: AsyncSession,
identity_id: str,
new_password: str,
) -> Identity | None:
identity = await get_identity_by_id(session, identity_id)
if not identity:
return None
identity.password_hash = await run_cpu(hash_password, new_password)
await session.refresh(identity)
return identity
async def change_identity_password(
session: AsyncSession,
identity_id: str,
current_password: str,
new_password: str,
) -> str | None:
"""Возвращает None при успехе, иначе код: no_password | wrong_password."""
identity = await get_identity_by_id(session, identity_id)
if not identity:
return "wrong_password"
if not identity.password_hash:
return "no_password"
if not await run_cpu(check_password, current_password, identity.password_hash):
return "wrong_password"
identity.password_hash = await run_cpu(hash_password, new_password)
await session.refresh(identity)
return None
async def ensure_billing_user_for_identity(session: AsyncSession, identity: Identity) -> int:
from database.users import add_user, check_user_exists
if identity.tg_id is not None:
tid = int(identity.tg_id)
if not await check_user_exists(session, tid):
await add_user(session, tid)
ur = await session.execute(select(User).where(User.tg_id == tid).limit(1))
u = ur.scalar_one()
await session.execute(update(User).where(User.id == u.id).values(identity_id=identity.id))
return int(u.id)
res = await session.execute(select(User).where(User.identity_id == identity.id))
row = res.scalars().first()
if row is not None:
return int(row.id)
new_u = User(identity_id=identity.id, tg_id=None)
session.add(new_u)
await session.flush()
return int(new_u.id)
async def merge_billing_user_into_telegram(session: AsyncSession, identity_id: str, telegram_tg_id: int) -> None:
from database.models import (
CouponUsage,
Gift,
GiftUsage,
Key,
Notification,
Payment,
Referral,
ScheduledBroadcast,
TemporaryData,
)
from database.access.resolution import resolve_user_optional
from database.users import invalidate_balance_cache, invalidate_profile_cache, update_balance
res = await session.execute(select(User).where(User.identity_id == identity_id))
rows = res.scalars().all()
if not rows:
return
billing = rows[0]
src_uid = int(billing.id)
dst_tg = int(telegram_tg_id)
if billing.tg_id is not None and int(billing.tg_id) > 0:
return
dst_u = await resolve_user_optional(session, dst_tg)
if dst_u is None:
new_u = User(
tg_id=dst_tg,
identity_id=identity_id,
username=billing.username,
first_name=billing.first_name,
last_name=billing.last_name,
language_code=billing.language_code,
is_bot=billing.is_bot or False,
balance=float(billing.balance or 0.0),
trial=int(billing.trial or 0),
preferred_currency=billing.preferred_currency or "RUB",
source_code=billing.source_code,
)
session.add(new_u)
await session.flush()
dst_uid = int(new_u.id)
else:
dst_uid = int(dst_u.id)
bal = float(billing.balance or 0.0)
if bal:
await update_balance(session, dst_uid, bal)
st = int(billing.trial or 0)
dt_r = await session.execute(select(User.trial).where(User.id == dst_uid))
dt_val = dt_r.scalar_one_or_none()
if dt_val is not None and st > int(dt_val or 0):
await session.execute(update(User).where(User.id == dst_uid).values(trial=st))
await session.execute(update(Key).where(Key.user_id == src_uid).values(user_id=dst_uid))
await session.execute(update(Payment).where(Payment.user_id == src_uid).values(user_id=dst_uid))
await session.execute(
text(
"DELETE FROM notifications AS n1 USING notifications AS n2 "
"WHERE n1.user_id = :src AND n2.user_id = :dst AND n1.notification_type = n2.notification_type"
),
{"src": src_uid, "dst": dst_uid},
)
await session.execute(update(Notification).where(Notification.user_id == src_uid).values(user_id=dst_uid))
await session.execute(update(Gift).where(Gift.sender_user_id == src_uid).values(sender_user_id=dst_uid))
await session.execute(
update(Gift).where(Gift.recipient_user_id == src_uid).values(recipient_user_id=dst_uid)
)
await session.execute(
text(
"DELETE FROM gift_usages AS g1 USING gift_usages AS g2 "
"WHERE g1.user_id = :src AND g2.user_id = :dst AND g1.gift_id = g2.gift_id"
),
{"src": src_uid, "dst": dst_uid},
)
await session.execute(update(GiftUsage).where(GiftUsage.user_id == src_uid).values(user_id=dst_uid))
await session.execute(
text(
"DELETE FROM coupon_usages AS c1 USING coupon_usages AS c2 "
"WHERE c1.user_id = :src AND c2.user_id = :dst AND c1.coupon_id = c2.coupon_id"
),
{"src": src_uid, "dst": dst_uid},
)
await session.execute(update(CouponUsage).where(CouponUsage.user_id == src_uid).values(user_id=dst_uid))
await session.execute(update(TemporaryData).where(TemporaryData.user_id == src_uid).values(user_id=dst_uid))
await session.execute(
update(ScheduledBroadcast)
.where(ScheduledBroadcast.created_by_user_id == src_uid)
.values(created_by_user_id=dst_uid)
)
await session.execute(
text(
"DELETE FROM referrals AS r1 USING referrals AS r2 "
"WHERE r1.referred_user_id = :src AND r2.referred_user_id = :dst "
"AND r1.referrer_user_id = r2.referrer_user_id"
),
{"src": src_uid, "dst": dst_uid},
)
await session.execute(
text(
"DELETE FROM referrals AS r1 USING referrals AS r2 "
"WHERE r1.referrer_user_id = :src AND r2.referrer_user_id = :dst "
"AND r1.referred_user_id = r2.referred_user_id"
),
{"src": src_uid, "dst": dst_uid},
)
await session.execute(
update(Referral).where(Referral.referred_user_id == src_uid).values(referred_user_id=dst_uid)
)
await session.execute(
update(Referral).where(Referral.referrer_user_id == src_uid).values(referrer_user_id=dst_uid)
)
await refresh_tg_mirrors_for_user(session, dst_uid)
await session.execute(delete(User).where(User.id == src_uid))
await session.execute(update(User).where(User.id == dst_uid).values(identity_id=identity_id))
await invalidate_balance_cache(src_uid)
await invalidate_profile_cache(src_uid)
await invalidate_balance_cache(dst_uid)
await invalidate_profile_cache(dst_uid)
async def resolve_tg_id(session: AsyncSession, identity_id: str) -> int | None:
"""По identity_id возвращает внутренний user id (users.id) для биллинга и ключей."""
identity = await get_identity_by_id(session, identity_id)
if not identity:
return None
return await ensure_billing_user_for_identity(session, identity)
async def attach_email(session: AsyncSession, identity_id: str, email: str) -> Identity | None:
@@ -164,7 +361,6 @@ async def attach_email(session: AsyncSession, identity_id: str, email: str) -> I
if existing and existing.id != identity_id:
return None
identity.email = email_clean
await session.commit()
await session.refresh(identity)
return identity
@@ -177,12 +373,15 @@ async def attach_telegram(session: AsyncSession, identity_id: str, tg_id: int) -
existing = await get_identity_by_tg_id(session, tg_id)
if existing and existing.id != identity_id:
return None
await merge_billing_user_into_telegram(session, identity_id, tg_id)
identity = await get_identity_by_id(session, identity_id)
if not identity:
return None
identity.tg_id = tg_id
admin_row = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
if admin_row.scalar_one_or_none():
identity.is_admin = True
await session.execute(User.__table__.update().where(User.tg_id == tg_id).values(identity_id=identity_id))
await session.commit()
await session.refresh(identity)
return identity
@@ -196,6 +395,5 @@ async def get_or_create_identity_for_tg(session: AsyncSession, tg_id: int) -> Id
session.add(identity)
await session.flush()
await session.execute(User.__table__.update().where(User.tg_id == tg_id).values(identity_id=identity.id))
await session.commit()
await session.refresh(identity)
return identity
+36 -41
View File
@@ -6,7 +6,6 @@ from datetime import datetime
from itertools import cycle
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from config import USE_COUNTRY_SELECTION
@@ -76,53 +75,49 @@ async def import_keys_from_3xui_db(db_path: str, session: AsyncSession) -> tuple
user_exists = await session.execute(select(User).where(User.tg_id == tg_id))
if not user_exists.scalar():
try:
session.add(
User(
tg_id=tg_id,
username=None,
first_name=None,
last_name=None,
language_code=None,
is_bot=False,
balance=0.0,
trial=1,
source_code=None,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
)
session.add(
User(
tg_id=tg_id,
username=None,
first_name=None,
last_name=None,
language_code=None,
is_bot=False,
balance=0.0,
trial=1,
source_code=None,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
)
except SQLAlchemyError as e:
await session.rollback()
raise RuntimeError(f"Ошибка при импорте пользователя tg_id={tg_id}") from e
)
await session.flush()
user_row = await session.execute(select(User.id).where(User.tg_id == tg_id))
bill_uid = user_row.scalar_one()
key_exists = await session.execute(select(Key).where(Key.client_id == client_id))
if key_exists.scalar():
skipped += 1
continue
try:
session.add(
Key(
tg_id=tg_id,
client_id=client_id,
email=email,
created_at=created_at,
expiry_time=expiry_time,
key="",
server_id=server_id,
remnawave_link=None,
tariff_id=None,
is_frozen=False,
alias=None,
notified=False,
notified_24h=False,
)
session.add(
Key(
user_id=bill_uid,
tg_id=tg_id,
client_id=client_id,
email=email,
created_at=created_at,
expiry_time=expiry_time,
key="",
server_id=server_id,
remnawave_link=None,
tariff_id=None,
is_frozen=False,
alias=None,
notified=False,
notified_24h=False,
)
imported += 1
except SQLAlchemyError as e:
await session.rollback()
raise RuntimeError(f"Ошибка при импорте ключа client_id={client_id}") from e
)
imported += 1
await session.commit()
return imported, skipped
+325 -124
View File
@@ -1,9 +1,8 @@
import asyncio
from datetime import datetime
from datetime import UTC, datetime
from types import SimpleNamespace
from sqlalchemy import delete, func, select, text, update
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from core.cache_config import (
@@ -12,6 +11,7 @@ from core.cache_config import (
KEYS_LIST_CACHE_TTL_SEC,
)
from core.redis_cache import cache_delete, cache_get, cache_key, cache_set
from database.access.resolution import resolve_user_optional
from database.models import Key, Tariff, User
from database.users import invalidate_profile_cache, invalidate_user_snapshot
from logger import logger
@@ -25,10 +25,22 @@ async def invalidate_key_email(client_id: str) -> None:
await cache_delete(cache_key("key_email", client_id))
async def invalidate_keys_list(tg_id: int) -> None:
await cache_delete(cache_key("keys_list", tg_id))
await cache_delete(cache_key("key_count", tg_id))
await invalidate_profile_cache(tg_id)
async def _purge_keys_cache_ids(*ids: int) -> None:
for i in ids:
await cache_delete(cache_key("keys_list", i))
await cache_delete(cache_key("key_count", i))
await invalidate_profile_cache(i)
async def invalidate_keys_list(session: AsyncSession, legacy_user_ref: int) -> None:
u = await resolve_user_optional(session, legacy_user_ref)
if u is None:
await _purge_keys_cache_ids(legacy_user_ref)
return
if u.tg_id is not None:
await _purge_keys_cache_ids(u.id, u.tg_id)
else:
await _purge_keys_cache_ids(u.id)
async def invalidate_key_details_by_client_id(session: AsyncSession, client_id: str) -> None:
@@ -45,7 +57,7 @@ async def invalidate_key_details_by_client_id(session: AsyncSession, client_id:
async def store_key(
session: AsyncSession,
tg_id: int,
legacy_user_ref: int,
client_id: str,
email: str,
expiry_time: int,
@@ -61,71 +73,72 @@ async def store_key(
current_traffic_limit: int | None = None,
):
"""Сохраняет или обновляет ключ подписки."""
try:
exists = await session.execute(select(Key).where(Key.tg_id == tg_id, Key.client_id == client_id))
existing_key = exists.scalar_one_or_none()
u = await resolve_user_optional(session, legacy_user_ref)
if u is None:
raise ValueError(f"Пользователь не найден для ключа: {legacy_user_ref}")
uid = u.id
exists = await session.execute(select(Key).where(Key.user_id == uid, Key.client_id == client_id))
existing_key = exists.scalar_one_or_none()
if existing_key:
values: dict = {
"email": email,
"expiry_time": expiry_time,
"key": key,
"server_id": server_id,
"remnawave_link": remnawave_link,
"tariff_id": tariff_id,
"alias": alias,
}
if existing_key:
values: dict = {
"email": email,
"expiry_time": expiry_time,
"key": key,
"server_id": server_id,
"remnawave_link": remnawave_link,
"tariff_id": tariff_id,
"alias": alias,
"tg_id": u.tg_id,
}
if selected_device_limit is not None:
values["selected_device_limit"] = selected_device_limit
if selected_traffic_limit is not None:
values["selected_traffic_limit"] = selected_traffic_limit
if selected_price_rub is not None:
values["selected_price_rub"] = selected_price_rub
if current_device_limit is not None:
values["current_device_limit"] = current_device_limit
if current_traffic_limit is not None:
values["current_traffic_limit"] = current_traffic_limit
if selected_device_limit is not None:
values["selected_device_limit"] = selected_device_limit
if selected_traffic_limit is not None:
values["selected_traffic_limit"] = selected_traffic_limit
if selected_price_rub is not None:
values["selected_price_rub"] = selected_price_rub
if current_device_limit is not None:
values["current_device_limit"] = current_device_limit
if current_traffic_limit is not None:
values["current_traffic_limit"] = current_traffic_limit
await session.execute(update(Key).where(Key.tg_id == tg_id, Key.client_id == client_id).values(**values))
logger.info(f"[Store Key] Ключ обновлён: tg_id={tg_id}, client_id={client_id}, server_id={server_id}")
else:
if current_device_limit is None:
current_device_limit = selected_device_limit
if current_traffic_limit is None:
current_traffic_limit = selected_traffic_limit
await session.execute(update(Key).where(Key.user_id == uid, Key.client_id == client_id).values(**values))
logger.info(f"[Store Key] Ключ обновлён: user_id={uid}, client_id={client_id}, server_id={server_id}")
else:
if current_device_limit is None:
current_device_limit = selected_device_limit
if current_traffic_limit is None:
current_traffic_limit = selected_traffic_limit
new_key = Key(
tg_id=tg_id,
client_id=client_id,
email=email,
created_at=int(datetime.utcnow().timestamp() * 1000),
expiry_time=expiry_time,
key=key,
server_id=server_id,
remnawave_link=remnawave_link,
tariff_id=tariff_id,
alias=alias,
selected_device_limit=selected_device_limit,
selected_traffic_limit=selected_traffic_limit,
selected_price_rub=selected_price_rub,
current_device_limit=current_device_limit,
current_traffic_limit=current_traffic_limit,
)
add_result = session.add(new_key)
if asyncio.iscoroutine(add_result):
await add_result
logger.info(f"[Store Key] Ключ создан: tg_id={tg_id}, client_id={client_id}, server_id={server_id}")
new_key = Key(
user_id=uid,
tg_id=u.tg_id,
client_id=client_id,
email=email,
created_at=int(datetime.now(UTC).timestamp() * 1000),
expiry_time=expiry_time,
key=key,
server_id=server_id,
remnawave_link=remnawave_link,
tariff_id=tariff_id,
alias=alias,
selected_device_limit=selected_device_limit,
selected_traffic_limit=selected_traffic_limit,
selected_price_rub=selected_price_rub,
current_device_limit=current_device_limit,
current_traffic_limit=current_traffic_limit,
)
add_result = session.add(new_key)
if asyncio.iscoroutine(add_result):
await add_result
logger.info(f"[Store Key] Ключ создан: user_id={uid}, client_id={client_id}, server_id={server_id}")
await session.commit()
invalidate_user_snapshot(tg_id)
await invalidate_keys_list(tg_id)
await invalidate_key_details(email)
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при сохранении ключа: {e}")
await session.rollback()
raise
invalidate_user_snapshot(uid)
if u.tg_id is not None:
invalidate_user_snapshot(u.tg_id)
await invalidate_keys_list(session, uid)
await invalidate_key_details(email)
def _key_to_cache_dict(k: Key) -> dict:
@@ -143,12 +156,16 @@ def _key_to_cache_dict(k: Key) -> dict:
}
async def get_keys(session: AsyncSession, tg_id: int):
ckey = cache_key("keys_list", tg_id)
async def get_keys(session: AsyncSession, legacy_user_ref: int):
u = await resolve_user_optional(session, legacy_user_ref)
if u is None:
return []
uid = u.id
ckey = cache_key("keys_list", uid)
cached = await cache_get(ckey)
if isinstance(cached, list):
return [SimpleNamespace(**d) for d in cached]
result = await session.execute(select(Key).where(Key.tg_id == tg_id))
result = await session.execute(select(Key).where(Key.user_id == uid))
rows = result.scalars().all()
serialized = [_key_to_cache_dict(k) for k in rows]
await cache_set(ckey, serialized, KEYS_LIST_CACHE_TTL_SEC)
@@ -160,24 +177,33 @@ async def get_all_keys(session: AsyncSession):
return result.scalars().all()
async def get_key_by_server(session: AsyncSession, tg_id: int, client_id: str):
stmt = select(Key).where(Key.tg_id == tg_id, Key.client_id == client_id)
async def get_key_by_server(session: AsyncSession, legacy_user_ref: int, client_id: str):
u = await resolve_user_optional(session, legacy_user_ref)
if u is None:
return None
stmt = select(Key).where(Key.user_id == u.id, Key.client_id == client_id)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def get_key_by_email(session: AsyncSession, email: str, tg_id: int | None = None) -> Key | None:
async def get_key_by_email(session: AsyncSession, email: str, legacy_user_ref: int | None = None) -> Key | None:
stmt = select(Key).where(Key.email == email)
if tg_id is not None:
stmt = stmt.where(Key.tg_id == tg_id)
if legacy_user_ref is not None:
u = await resolve_user_optional(session, legacy_user_ref)
if u is None:
return None
stmt = stmt.where(Key.user_id == u.id)
result = await session.execute(stmt.limit(1))
return result.scalar_one_or_none()
async def get_key_by_client_id(session: AsyncSession, client_id: str, tg_id: int | None = None) -> Key | None:
async def get_key_by_client_id(session: AsyncSession, client_id: str, legacy_user_ref: int | None = None) -> Key | None:
stmt = select(Key).where(Key.client_id == client_id)
if tg_id is not None:
stmt = stmt.where(Key.tg_id == tg_id)
if legacy_user_ref is not None:
u = await resolve_user_optional(session, legacy_user_ref)
if u is None:
return None
stmt = stmt.where(Key.user_id == u.id)
result = await session.execute(stmt.limit(1))
return result.scalar_one_or_none()
@@ -218,15 +244,15 @@ async def get_key_details(session: AsyncSession, email: str) -> dict | None:
if isinstance(cached, dict):
return cached
stmt = select(Key, User).join(User, Key.tg_id == User.tg_id).where(Key.email == email)
stmt = select(Key, User).join(User, Key.user_id == User.id).where(Key.email == email)
result = await session.execute(stmt)
row = result.first()
if not row:
return None
key, user = row
expiry_date = datetime.utcfromtimestamp(key.expiry_time / 1000)
current_date = datetime.utcnow()
expiry_date = datetime.fromtimestamp(key.expiry_time / 1000, UTC)
current_date = datetime.now(UTC)
time_left = expiry_date - current_date
if time_left.total_seconds() <= 0:
@@ -267,39 +293,155 @@ async def get_key_details(session: AsyncSession, email: str) -> dict | None:
return out
async def get_key_count(session: AsyncSession, tg_id: int) -> int:
cached = await cache_get(cache_key("key_count", tg_id))
async def get_key_count(session: AsyncSession, legacy_user_ref: int) -> int:
u = await resolve_user_optional(session, legacy_user_ref)
if u is None:
return 0
uid = u.id
cached = await cache_get(cache_key("key_count", uid))
if cached is not None:
try:
return int(cached)
except (TypeError, ValueError):
pass
result = await session.execute(select(func.count()).select_from(Key).where(Key.tg_id == tg_id))
result = await session.execute(select(func.count()).select_from(Key).where(Key.user_id == uid))
count = result.scalar() or 0
await cache_set(cache_key("key_count", tg_id), count, KEY_COUNT_CACHE_TTL_SEC)
await cache_set(cache_key("key_count", uid), count, KEY_COUNT_CACHE_TTL_SEC)
return count
async def delete_key(session: AsyncSession, identifier: int | str, commit: bool = True):
tg_id_for_cache = None
async def get_key_by_user_and_email(session: AsyncSession, user_id: int, email: str) -> Key | None:
"""Возвращает ORM-объект Key по паре (users.id, email) или None."""
result = await session.execute(
select(Key).where(Key.user_id == int(user_id), Key.email == email)
)
return result.scalar_one_or_none()
async def delete_key_by_user_and_email(session: AsyncSession, user_id: int, email: str) -> None:
"""Удаляет ключ по паре (users.id, email). Commit — ответственность caller'а."""
await session.execute(
delete(Key).where(Key.user_id == int(user_id), Key.email == email)
)
async def get_user_keys_with_servers_by_email(
session: AsyncSession, user_id: int, email: str
) -> list[tuple[str, str, dict]]:
"""Возвращает ключи пользователя + инфо о серверах (join Key × Server).
Каждый элемент ``(client_id, server_id, server_info_dict)``. Join
делается по (Key.server_id == Server.server_name OR Server.cluster_name),
чтобы поддержать и country-mode (server_id = cluster), и cluster-mode
(server_id = server_name).
Используется в ``services.operations.traffic.get_user_traffic``.
"""
from sqlalchemy import or_
from database.models import Server
join_cond = or_(
Key.server_id == Server.server_name,
Key.server_id == Server.cluster_name,
)
result = await session.execute(
select(Key.client_id, Key.server_id, Server)
.select_from(Key)
.join(Server, join_cond)
.where(Server.enabled.is_(True), Key.user_id == int(user_id), Key.email == email)
)
rows = []
for client_id, server_id, server in result.all():
rows.append((
client_id,
server_id,
{
"server_name": server.server_name,
"cluster_name": server.cluster_name,
"api_url": server.api_url,
"panel_type": server.panel_type,
},
))
return rows
async def get_key_client_id_by_email_and_server(
session: AsyncSession, email: str, server_id: str
) -> str | None:
"""Возвращает ``client_id`` первого ключа для пары (email, server_id).
Используется для remnawave traffic reset, где нам нужен только client_id,
без остальных полей ключа.
"""
result = await session.execute(
select(Key.client_id)
.where(Key.email == email, Key.server_id == server_id)
.limit(1)
)
return result.scalar()
async def count_keys_by_server_id(session: AsyncSession, server_id: str) -> int:
"""Сколько всего ключей привязано к указанному server_id (кластеру или серверу).
Используется для проверки max_keys лимита. ``server_id`` строка
(у ``keys.server_id`` колонка типа String, содержит либо cluster_name,
либо server_name в зависимости от страны/кластера).
"""
result = await session.execute(
select(func.count()).select_from(Key).where(Key.server_id == server_id)
)
return int(result.scalar() or 0)
async def get_all_key_server_ids(session: AsyncSession) -> list[str]:
"""Список всех ``server_id`` из таблицы keys (с повторениями).
Используется в ``services.clusters.select_cluster`` для подсчёта загрузки
кластеров. Возвращаем только server_id строки без подгрузки остальных
полей, чтобы не тянуть сотни мегабайт для огромных deployments.
"""
result = await session.execute(select(Key.server_id))
return [row[0] for row in result.all() if row[0] is not None]
async def count_active_keys_for_user(session: AsyncSession, user_id: int) -> int:
"""Количество незамороженных ключей у пользователя (по internal users.id).
Отличается от `get_key_count`: не кэшируется и явно исключает замороженные.
Используется в проверке "новый пользователь" для купонных правил.
"""
result = await session.execute(
select(func.count())
.select_from(Key)
.where(Key.user_id == int(user_id), Key.is_frozen.is_(False))
)
return int(result.scalar() or 0)
async def delete_key(session: AsyncSession, identifier: int | str):
legacy_for_cache = None
email_for_cache = None
if isinstance(identifier, str):
res = await session.execute(
select(Key.tg_id, Key.email).where(Key.client_id == identifier).limit(1)
select(Key.user_id, Key.email).where(Key.client_id == identifier).limit(1)
)
row = res.first()
if row:
tg_id_for_cache, email_for_cache = row[0], row[1]
legacy_for_cache, email_for_cache = row[0], row[1]
await cache_delete(cache_key("key_email", identifier))
await session.execute(delete(Key).where(Key.client_id == identifier))
else:
tg_id_for_cache = identifier
stmt = delete(Key).where(Key.tg_id == identifier if isinstance(identifier, int) else Key.client_id == identifier)
await session.execute(stmt)
if commit:
await session.commit()
if tg_id_for_cache is not None:
invalidate_user_snapshot(tg_id_for_cache)
await invalidate_keys_list(tg_id_for_cache)
u = await resolve_user_optional(session, identifier)
if u is None:
logger.info(f"Ключ не удалён: пользователь {identifier} не найден")
return
legacy_for_cache = u.id
await session.execute(delete(Key).where(Key.user_id == u.id))
if legacy_for_cache is not None:
invalidate_user_snapshot(legacy_for_cache)
await invalidate_keys_list(session, legacy_for_cache)
if email_for_cache is not None:
await invalidate_key_details(str(email_for_cache))
logger.info(f"Ключ с идентификатором {identifier} удалён")
@@ -307,7 +449,6 @@ async def delete_key(session: AsyncSession, identifier: int | str, commit: bool
async def update_key_expiry(session: AsyncSession, client_id: str, new_expiry_time: int):
await session.execute(update(Key).where(Key.client_id == client_id).values(expiry_time=new_expiry_time))
await session.commit()
await invalidate_key_details_by_client_id(session, client_id)
logger.info(f"Срок действия ключа {client_id} обновлён до {new_expiry_time}")
@@ -317,59 +458,120 @@ async def get_client_id_by_email(session: AsyncSession, email: str):
return result.scalar_one_or_none()
async def update_key_notified(session: AsyncSession, tg_id: int, client_id: str):
await session.execute(update(Key).where(Key.tg_id == tg_id, Key.client_id == client_id).values(notified=True))
await session.commit()
await invalidate_keys_list(tg_id)
async def update_key_notified(session: AsyncSession, legacy_user_ref: int, client_id: str):
u = await resolve_user_optional(session, legacy_user_ref)
if u is None:
return
await session.execute(update(Key).where(Key.user_id == u.id, Key.client_id == client_id).values(notified=True))
await invalidate_keys_list(session, u.id)
await invalidate_key_details_by_client_id(session, client_id)
async def mark_key_as_frozen(session: AsyncSession, tg_id: int, client_id: str, time_left: int):
async def mark_key_as_frozen(session: AsyncSession, legacy_user_ref: int, client_id: str, time_left: int):
u = await resolve_user_optional(session, legacy_user_ref)
if u is None:
return
await session.execute(
text(
"""
UPDATE keys
SET expiry_time = :expiry,
is_frozen = TRUE
WHERE tg_id = :tg_id
WHERE user_id = :user_id
AND client_id = :client_id
"""
),
{"expiry": time_left, "tg_id": tg_id, "client_id": client_id},
{"expiry": time_left, "user_id": u.id, "client_id": client_id},
)
await invalidate_keys_list(tg_id)
await invalidate_keys_list(session, u.id)
await invalidate_key_details_by_client_id(session, client_id)
async def mark_key_as_unfrozen(
session: AsyncSession,
tg_id: int,
legacy_user_ref: int,
client_id: str,
new_expiry_time: int,
):
u = await resolve_user_optional(session, legacy_user_ref)
if u is None:
return
await session.execute(
text(
"""
UPDATE keys
SET expiry_time = :expiry,
is_frozen = FALSE
WHERE tg_id = :tg_id
WHERE user_id = :user_id
AND client_id = :client_id
"""
),
{"expiry": new_expiry_time, "tg_id": tg_id, "client_id": client_id},
{"expiry": new_expiry_time, "user_id": u.id, "client_id": client_id},
)
await invalidate_keys_list(tg_id)
await invalidate_keys_list(session, u.id)
await invalidate_key_details_by_client_id(session, client_id)
async def update_key_tariff(session: AsyncSession, client_id: str, tariff_id: int):
await session.execute(update(Key).where(Key.client_id == client_id).values(tariff_id=tariff_id))
await session.commit()
await invalidate_key_details_by_client_id(session, client_id)
logger.info(f"Тариф ключа {client_id} обновлён на {tariff_id}")
async def update_key_renewal_snapshot(
session: AsyncSession,
email: str,
*,
tariff_id: int,
selected_device_limit: int | None = None,
current_device_limit: int | None = None,
selected_traffic_limit: int | None = None,
current_traffic_limit: int | None = None,
apply_limits: bool = True,
) -> None:
"""Обновляет tariff_id и (опционально) лимиты ключа после продления.
``apply_limits=True`` выставить все четыре лимита (для non-configurable
тарифов). ``apply_limits=False`` обновить только ``tariff_id``, лимиты
не трогать (configurable-тарифы обновляют их через `save_key_config_with_mode`).
"""
values: dict = {"tariff_id": tariff_id}
if apply_limits:
values["selected_device_limit"] = selected_device_limit
values["current_device_limit"] = current_device_limit
values["selected_traffic_limit"] = selected_traffic_limit
values["current_traffic_limit"] = current_traffic_limit
await session.execute(update(Key).where(Key.email == email).values(**values))
await invalidate_key_details(email)
async def update_key_post_creation_snapshot(
session: AsyncSession,
*,
user_id: int,
email: str,
selected_device_limit: int | None,
selected_traffic_limit: int | None,
selected_price_rub: int | None,
) -> None:
"""Дозаписывает выбранные пользователем параметры ключа сразу после создания.
Используется из `services.keys.create_vpn_key_headless` тариф/лимиты не
всегда известны на момент `create_key_on_cluster`, поэтому после него
идёт snapshot-апдейт для полей, которые нужны для отображения в UI.
"""
await session.execute(
update(Key)
.where(Key.user_id == int(user_id), Key.email == email)
.values(
selected_device_limit=selected_device_limit,
selected_traffic_limit=selected_traffic_limit,
selected_price_rub=selected_price_rub,
)
)
await invalidate_key_details(email)
async def get_subscription_link(session: AsyncSession, email: str) -> str | None:
result = await session.execute(select(func.coalesce(Key.key, Key.remnawave_link)).where(Key.email == email))
return result.scalar_one_or_none()
@@ -377,7 +579,6 @@ async def get_subscription_link(session: AsyncSession, email: str) -> str | None
async def update_key_client_id(session: AsyncSession, email: str, new_client_id: str):
await session.execute(update(Key).where(Key.email == email).values(client_id=new_client_id))
await session.commit()
await invalidate_key_details(email)
logger.info(f"client_id обновлён для {email} -> {new_client_id}")
@@ -385,7 +586,6 @@ async def update_key_client_id(session: AsyncSession, email: str, new_client_id:
async def update_key_link(session: AsyncSession, email: str, link: str) -> bool:
q = update(Key).where(Key.email == email).values(key=link).returning(Key.client_id)
res = await session.execute(q)
await session.commit()
ok = res.scalar_one_or_none() is not None
if ok:
await invalidate_key_details(email)
@@ -403,7 +603,6 @@ async def update_key_subscription_links(session: AsyncSession, email: str, link:
.returning(Key.client_id)
)
res = await session.execute(stmt)
await session.commit()
ok = res.scalar_one_or_none() is not None
if ok:
await invalidate_key_details(email)
@@ -444,10 +643,13 @@ async def save_key_config_with_mode(
await invalidate_key_details(email)
async def reset_key_tariff_state(session: AsyncSession, tg_id: int, email: str, tariff_id: int) -> None:
async def reset_key_tariff_state(session: AsyncSession, legacy_user_ref: int, email: str, tariff_id: int) -> None:
u = await resolve_user_optional(session, legacy_user_ref)
if u is None:
return
await session.execute(
update(Key)
.where(Key.tg_id == tg_id, Key.email == email)
.where(Key.user_id == u.id, Key.email == email)
.values(
tariff_id=tariff_id,
selected_device_limit=None,
@@ -457,25 +659,27 @@ async def reset_key_tariff_state(session: AsyncSession, tg_id: int, email: str,
selected_price_rub=None,
)
)
await session.commit()
await invalidate_keys_list(tg_id)
await invalidate_keys_list(session, u.id)
await invalidate_key_details(email)
async def save_key_tariff_selection(
session: AsyncSession,
tg_id: int,
legacy_user_ref: int,
email: str,
tariff_id: int,
selected_devices: int | None,
selected_traffic_gb: int | None,
) -> None:
u = await resolve_user_optional(session, legacy_user_ref)
if u is None:
return
selected_devices_val = int(selected_devices) if selected_devices is not None else None
selected_traffic_val = int(selected_traffic_gb) if selected_traffic_gb is not None and int(selected_traffic_gb) > 0 else None
await session.execute(
update(Key)
.where(Key.tg_id == tg_id, Key.email == email)
.where(Key.user_id == u.id, Key.email == email)
.values(
tariff_id=tariff_id,
selected_device_limit=selected_devices_val,
@@ -485,8 +689,7 @@ async def save_key_tariff_selection(
selected_price_rub=None,
)
)
await session.commit()
await invalidate_keys_list(tg_id)
await invalidate_keys_list(session, u.id)
await invalidate_key_details(email)
@@ -510,7 +713,6 @@ async def save_admin_key_config(
selected_price_rub=selected_price,
)
)
await session.commit()
await invalidate_key_details(email)
@@ -527,6 +729,5 @@ async def reset_key_current_limits_to_selected(session: AsyncSession, client_id:
),
{"client_id": client_id},
)
await session.commit()
await invalidate_key_details_by_client_id(session, client_id)
logger.info(f"Текущие лимиты ключа {client_id} сброшены к выбранным")
+1
View File
@@ -0,0 +1 @@
from .schema_upgrade import *
File diff suppressed because it is too large Load Diff
-405
View File
@@ -1,405 +0,0 @@
import secrets
import uuid
from datetime import datetime
from sqlalchemy import (
JSON,
BigInteger,
Boolean,
Column,
DateTime,
Float,
ForeignKey,
Index,
Integer,
Numeric,
String,
Text,
UniqueConstraint,
text as sql_text,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, declarative_base, mapped_column, relationship
Base = declarative_base()
class DictLikeMixin:
def __getitem__(self, key):
return getattr(self, key)
def get(self, key, default=None):
return getattr(self, key, default)
def to_dict(self):
return {column.name: getattr(self, column.name) for column in self.__table__.columns}
class Identity(DictLikeMixin, Base):
"""Слой идентификации: к одному identity можно привязать email и/или Telegram (tg_id)."""
__tablename__ = "identities"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
email = Column(String(255), unique=True, nullable=True, index=True)
tg_id = Column(BigInteger, unique=True, nullable=True, index=True)
api_token_hash = Column(String(64), nullable=True, index=True)
token_issued_at = Column(DateTime, nullable=True)
password_hash = Column(String(64), nullable=True)
is_admin = Column(Boolean, nullable=False, server_default=sql_text("false"))
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class User(DictLikeMixin, Base):
__tablename__ = "users"
tg_id = Column(BigInteger, primary_key=True)
identity_id = Column(
String(36),
ForeignKey("identities.id", ondelete="SET NULL", onupdate="CASCADE"),
nullable=True,
index=True,
)
username = Column(String)
first_name = Column(String)
last_name = Column(String)
language_code = Column(String)
is_bot = Column(Boolean, default=False)
balance = Column(Float, default=0.0)
trial = Column(Integer, default=0)
preferred_currency = Column(String(10), nullable=False, server_default="RUB", index=True)
source_code = Column(
String,
ForeignKey(
"tracking_sources.code",
ondelete="SET NULL",
onupdate="CASCADE",
),
nullable=True,
)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow)
class Key(DictLikeMixin, Base):
__tablename__ = "keys"
tg_id = Column(BigInteger, ForeignKey("users.tg_id"), nullable=False, index=True)
client_id = Column(String, primary_key=True)
email = Column(String, unique=True)
created_at = Column(BigInteger)
expiry_time = Column(BigInteger)
key = Column(String)
server_id = Column(String)
remnawave_link = Column(String)
tariff_id = Column(Integer, ForeignKey("tariffs.id", ondelete="SET NULL"))
is_frozen = Column(Boolean, default=False)
alias = Column(String)
notified = Column(Boolean, default=False)
notified_24h = Column(Boolean, default=False)
selected_device_limit = Column(Integer, nullable=True)
selected_traffic_limit = Column(BigInteger, nullable=True)
selected_price_rub = Column(Integer, nullable=True)
current_device_limit = Column(Integer, nullable=True)
current_traffic_limit = Column(BigInteger, nullable=True)
class Tariff(DictLikeMixin, Base):
__tablename__ = "tariffs"
id = Column(Integer, primary_key=True)
name = Column(String)
group_code = Column(String)
duration_days = Column(Integer)
price_rub = Column(Integer)
traffic_limit = Column(BigInteger, nullable=True)
device_limit = Column(Integer, nullable=True)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow)
subgroup_title = Column(String, nullable=True)
sort_order = Column(Integer, nullable=True)
vless = Column(Boolean, default=False)
external_squad: Mapped[str | None] = mapped_column(String(64), nullable=True)
configurable = Column(Boolean, nullable=False, server_default="false")
device_options = Column(JSONB, nullable=True)
traffic_options_gb = Column(JSONB, nullable=True)
device_step_rub = Column(Integer, nullable=True)
device_overrides = Column(JSONB, nullable=True)
traffic_step_rub = Column(Integer, nullable=True)
traffic_overrides = Column(JSONB, nullable=True)
class Server(DictLikeMixin, Base):
__tablename__ = "servers"
id = Column(Integer, primary_key=True, autoincrement=True)
cluster_name = Column(String)
server_name = Column(String, unique=True)
api_url = Column(String)
subscription_url = Column(String)
inbound_id = Column(String)
panel_type = Column(String)
max_keys = Column(Integer)
tariff_group = Column(String)
enabled = Column(Boolean, default=True)
subgroups = relationship("ServerSubgroup", back_populates="server", cascade="all, delete-orphan")
groups = relationship("ServerSpecialgroup", back_populates="server", cascade="all, delete-orphan")
class ServerSubgroup(DictLikeMixin, Base):
__tablename__ = "server_subgroups"
id = Column(Integer, primary_key=True, autoincrement=True)
server_id = Column(Integer, ForeignKey("servers.id", ondelete="CASCADE"), index=True, nullable=False)
group_code = Column(String, nullable=False)
subgroup_title = Column(String, nullable=False)
server = relationship("Server", back_populates="subgroups")
__table_args__ = (UniqueConstraint("server_id", "subgroup_title", name="uq_server_subgroup"),)
class ServerSpecialgroup(DictLikeMixin, Base):
__tablename__ = "server_specialgroups"
id = Column(Integer, primary_key=True, autoincrement=True)
server_id = Column(Integer, ForeignKey("servers.id", ondelete="CASCADE"), index=True, nullable=False)
group_code = Column(String, nullable=False)
server = relationship("Server")
__table_args__ = (UniqueConstraint("server_id", "group_code", name="uq_server_group"),)
class Payment(DictLikeMixin, Base):
__tablename__ = "payments"
id = Column(Integer, primary_key=True, autoincrement=True)
tg_id = Column(BigInteger, ForeignKey("users.tg_id"))
amount = Column(Float)
payment_system = Column(String)
status = Column(String)
created_at = Column(DateTime, default=datetime.utcnow)
original_amount = Column(Numeric(18, 8), nullable=True)
currency = Column(String(10), nullable=False, server_default="RUB")
payment_id = Column(String(128), nullable=True, index=True)
metadata_ = Column("metadata", JSONB, nullable=True)
class Coupon(DictLikeMixin, Base):
__tablename__ = "coupons"
id = Column(Integer, primary_key=True)
code = Column(String, unique=True)
amount = Column(Integer)
usage_limit = Column(Integer)
usage_count = Column(Integer, default=0)
is_used = Column(Boolean, default=False)
days = Column(Integer, nullable=True)
new_users_only = Column(Boolean, nullable=False, server_default=sql_text("false"))
percent = Column(Integer, nullable=True)
max_discount_amount = Column(Integer, nullable=True)
min_order_amount = Column(Integer, nullable=True)
class CouponUsage(DictLikeMixin, Base):
__tablename__ = "coupon_usages"
coupon_id = Column(Integer, ForeignKey("coupons.id", ondelete="CASCADE"), primary_key=True)
user_id = Column(BigInteger, primary_key=True)
used_at = Column(DateTime, default=datetime.utcnow)
class Referral(DictLikeMixin, Base):
__tablename__ = "referrals"
referred_tg_id = Column(BigInteger, ForeignKey("users.tg_id", ondelete="CASCADE"), primary_key=True)
referrer_tg_id = Column(BigInteger, ForeignKey("users.tg_id", ondelete="CASCADE"), primary_key=True)
reward_issued = Column(Boolean, default=False)
class Notification(DictLikeMixin, Base):
__tablename__ = "notifications"
tg_id = Column(BigInteger, ForeignKey("users.tg_id", ondelete="CASCADE"), primary_key=True)
notification_type = Column(String, primary_key=True)
last_notification_time = Column(DateTime, default=datetime.utcnow)
class ScheduledBroadcast(DictLikeMixin, Base):
__tablename__ = "scheduled_broadcasts"
__table_args__ = (
Index("ix_scheduled_broadcasts_status_time", "status", "scheduled_for"),
Index("ix_scheduled_broadcasts_creator_time", "created_by_tg_id", "created_at"),
)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
created_by_tg_id = Column(BigInteger, ForeignKey("users.tg_id", ondelete="SET NULL"), nullable=True, index=True)
status = Column(String(32), nullable=False, server_default=sql_text("'scheduled'"), index=True)
send_to = Column(String(32), nullable=False, index=True)
cluster_name = Column(String, nullable=True)
text = Column(Text, nullable=False)
photo = Column(String, nullable=True)
keyboard_json = Column(JSONB, nullable=True)
scheduled_for = Column(DateTime(timezone=True), nullable=False, index=True)
workers = Column(Integer, nullable=False, server_default=sql_text("5"))
messages_per_second = Column(Integer, nullable=False, server_default=sql_text("35"))
stats_json = Column(JSONB, nullable=True)
error_text = Column(Text, nullable=True)
started_at = Column(DateTime(timezone=True), nullable=True)
sent_at = Column(DateTime(timezone=True), nullable=True)
cancelled_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class Gift(DictLikeMixin, Base):
__tablename__ = "gifts"
gift_id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
sender_tg_id = Column(BigInteger, ForeignKey("users.tg_id"))
recipient_tg_id = Column(BigInteger, ForeignKey("users.tg_id"), nullable=True)
selected_months = Column(Integer)
expiry_time = Column(DateTime)
gift_link = Column(String)
created_at = Column(DateTime, default=datetime.utcnow)
is_used = Column(Boolean, default=False)
is_unlimited = Column(Boolean, default=False)
max_usages = Column(Integer, nullable=True)
tariff_id: Mapped[int | None] = mapped_column(ForeignKey("tariffs.id"))
selected_device_limit = Column(Integer, nullable=True)
selected_traffic_gb = Column(Integer, nullable=True)
selected_price_rub = Column(Integer, nullable=True)
class GiftUsage(DictLikeMixin, Base):
__tablename__ = "gift_usages"
gift_id = Column(String, ForeignKey("gifts.gift_id"), primary_key=True)
tg_id = Column(BigInteger, primary_key=True)
used_at = Column(DateTime, default=datetime.utcnow)
class ManualBan(DictLikeMixin, Base):
__tablename__ = "manual_bans"
tg_id = Column(BigInteger, primary_key=True)
banned_at = Column(DateTime(timezone=True), default=datetime.utcnow)
reason = Column(Text)
banned_by = Column(BigInteger)
until = Column(DateTime(timezone=True), nullable=True)
class TemporaryData(DictLikeMixin, Base):
__tablename__ = "temporary_data"
tg_id = Column(BigInteger, primary_key=True)
state = Column(String)
data = Column(JSON)
updated_at = Column(DateTime, default=datetime.utcnow)
class BlockedUser(DictLikeMixin, Base):
__tablename__ = "blocked_users"
tg_id = Column(BigInteger, primary_key=True)
class TrackingSource(DictLikeMixin, Base):
__tablename__ = "tracking_sources"
id = Column(Integer, primary_key=True)
name = Column(String)
code = Column(String, unique=True)
type = Column(String)
created_by = Column(BigInteger)
created_at = Column(DateTime, default=datetime.utcnow)
class AuditEvent(DictLikeMixin, Base):
"""События аудита (флоу пользователя)."""
__tablename__ = "audit_events"
__table_args__ = (
Index("ix_audit_events_tg_created", "actor_tg_id", "created_at"),
Index("ix_audit_events_identity_created", "actor_identity_id", "created_at"),
)
id = Column(Integer, primary_key=True, autoincrement=True)
event_type = Column(String(64), nullable=False, index=True)
channel = Column(String(32), nullable=False, index=True)
actor_identity_id = Column(
String(36),
ForeignKey("identities.id", ondelete="SET NULL", onupdate="CASCADE"),
nullable=True,
index=True,
)
actor_tg_id = Column(BigInteger, nullable=True, index=True)
path_or_handler = Column(String(255), nullable=False)
entity_type = Column(String(64), nullable=True, index=True)
entity_id = Column(String(255), nullable=True, index=True)
result = Column(String(32), nullable=False, server_default=sql_text("'success'"))
reason = Column(Text, nullable=True)
metadata_ = Column("metadata", JSONB, nullable=True)
request_id = Column(String(64), nullable=True, index=True)
created_at = Column(DateTime, default=datetime.utcnow, index=True)
class Admin(Base):
__tablename__ = "admins"
tg_id = Column(BigInteger, primary_key=True)
token = Column(String, unique=True, nullable=True)
description = Column(String, nullable=True)
role = Column(String, nullable=False, default="admin")
added_at = Column(DateTime, default=datetime.utcnow)
@staticmethod
def generate_token() -> str:
return secrets.token_urlsafe(32)
class Setting(DictLikeMixin, Base):
__tablename__ = "settings"
key = Column(String, primary_key=True)
value = Column(JSONB, nullable=True)
description = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class WebPage(DictLikeMixin, Base):
__tablename__ = "web_pages"
slug = Column(String(64), primary_key=True)
title = Column(String(255), nullable=True)
class WebTheme(DictLikeMixin, Base):
__tablename__ = "web_themes"
page_slug = Column(String(64), ForeignKey("web_pages.slug", ondelete="CASCADE"), primary_key=True)
tokens = Column(JSONB, nullable=False, default=dict)
class WebBlock(DictLikeMixin, Base):
__tablename__ = "web_blocks"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
page_slug = Column(String(64), ForeignKey("web_pages.slug", ondelete="CASCADE"), index=True, nullable=False)
order = Column(Integer, nullable=False, default=0)
type = Column(String(64), nullable=False)
data = Column(JSONB, nullable=False, default=dict)
+61
View File
@@ -0,0 +1,61 @@
from ._base import Base, DictLikeMixin
from .admin import Admin, Setting
from .audit import AuditEvent
from .coupons import Coupon, CouponUsage
from .gifts import Gift, GiftUsage
from .identity import Identity
from .keys import Key
from .notifications import Notification, ScheduledBroadcast
from .payments import Payment
from .referrals import Referral
from .servers import Server, ServerSpecialgroup, ServerSubgroup
from .tariffs import Tariff
from .users import BlockedUser, ManualBan, TemporaryData, TrackingSource, User
from .web import (
WebBlock,
WebCustomElementBuild,
WebFlow,
WebFlowEvent,
WebNotification,
WebPage,
WebPageVariant,
WebPageVariantBlock,
WebPushSubscription,
WebTheme,
)
__all__ = [
"Base",
"DictLikeMixin",
"Identity",
"User",
"ManualBan",
"TemporaryData",
"BlockedUser",
"TrackingSource",
"Key",
"Tariff",
"Server",
"ServerSubgroup",
"ServerSpecialgroup",
"Payment",
"Coupon",
"CouponUsage",
"Referral",
"Notification",
"ScheduledBroadcast",
"Gift",
"GiftUsage",
"AuditEvent",
"Admin",
"Setting",
"WebPage",
"WebTheme",
"WebBlock",
"WebPageVariant",
"WebPageVariantBlock",
"WebPushSubscription",
"WebNotification",
"WebFlow",
]
+21
View File
@@ -0,0 +1,21 @@
from sqlalchemy.orm import declarative_base
Base = declarative_base()
class DictLikeMixin:
"""Позволяет обращаться к ORM-объектам как к словарю.
Используется legacy-кодом, который мигрировал с dict-результатов asyncpg
на ORM и не хочет переписывать все `row["field"]` / `row.get("field")`.
"""
def __getitem__(self, key):
return getattr(self, key)
def get(self, key, default=None):
return getattr(self, key, default)
def to_dict(self):
return {column.name: getattr(self, column.name) for column in self.__table__.columns}
+32
View File
@@ -0,0 +1,32 @@
import secrets
from datetime import datetime
from sqlalchemy import BigInteger, Column, DateTime, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from ._base import Base, DictLikeMixin
class Admin(Base):
__tablename__ = "admins"
tg_id = Column(BigInteger, primary_key=True)
token = Column(String, unique=True, nullable=True)
description = Column(String, nullable=True)
role = Column(String, nullable=False, default="admin")
added_at = Column(DateTime, default=datetime.utcnow)
@staticmethod
def generate_token() -> str:
return secrets.token_urlsafe(32)
class Setting(DictLikeMixin, Base):
__tablename__ = "settings"
key = Column(String, primary_key=True)
value = Column(JSONB, nullable=True)
description = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+44
View File
@@ -0,0 +1,44 @@
from datetime import datetime
from sqlalchemy import (
BigInteger,
Column,
DateTime,
ForeignKey,
Index,
Integer,
String,
Text,
text as sql_text,
)
from sqlalchemy.dialects.postgresql import JSONB
from ._base import Base, DictLikeMixin
class AuditEvent(DictLikeMixin, Base):
"""События аудита (флоу пользователя)."""
__tablename__ = "audit_events"
__table_args__ = (
Index("ix_audit_events_tg_created", "actor_tg_id", "created_at"),
Index("ix_audit_events_identity_created", "actor_identity_id", "created_at"),
)
id = Column(Integer, primary_key=True, autoincrement=True)
event_type = Column(String(64), nullable=False, index=True)
channel = Column(String(32), nullable=False, index=True)
actor_identity_id = Column(
String(36),
ForeignKey("identities.id", ondelete="SET NULL", onupdate="CASCADE"),
nullable=True,
index=True,
)
actor_tg_id = Column(BigInteger, nullable=True, index=True)
path_or_handler = Column(String(255), nullable=False)
entity_type = Column(String(64), nullable=True, index=True)
entity_id = Column(String(255), nullable=True, index=True)
result = Column(String(32), nullable=False, server_default=sql_text("'success'"))
reason = Column(Text, nullable=True)
metadata_ = Column("metadata", JSONB, nullable=True)
request_id = Column(String(64), nullable=True, index=True)
created_at = Column(DateTime, default=datetime.utcnow, index=True)
+40
View File
@@ -0,0 +1,40 @@
from datetime import datetime
from sqlalchemy import (
BigInteger,
Boolean,
Column,
DateTime,
ForeignKey,
Integer,
String,
text as sql_text,
)
from ._base import Base, DictLikeMixin
class Coupon(DictLikeMixin, Base):
__tablename__ = "coupons"
id = Column(Integer, primary_key=True)
code = Column(String, unique=True)
amount = Column(Integer)
usage_limit = Column(Integer)
usage_count = Column(Integer, default=0)
is_used = Column(Boolean, default=False)
days = Column(Integer, nullable=True)
new_users_only = Column(Boolean, nullable=False, server_default=sql_text("false"))
percent = Column(Integer, nullable=True)
max_discount_amount = Column(Integer, nullable=True)
min_order_amount = Column(Integer, nullable=True)
class CouponUsage(DictLikeMixin, Base):
__tablename__ = "coupon_usages"
coupon_id = Column(Integer, ForeignKey("coupons.id", ondelete="CASCADE"), primary_key=True)
user_id = Column(BigInteger, primary_key=True)
tg_id = Column(BigInteger, nullable=True, index=True)
used_at = Column(DateTime, default=datetime.utcnow)
+39
View File
@@ -0,0 +1,39 @@
import uuid
from datetime import datetime
from sqlalchemy import BigInteger, Boolean, Column, DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from ._base import Base, DictLikeMixin
class Gift(DictLikeMixin, Base):
__tablename__ = "gifts"
gift_id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
sender_user_id = Column(BigInteger, nullable=True)
recipient_user_id = Column(BigInteger, nullable=True)
sender_tg_id = Column(BigInteger, ForeignKey("users.tg_id"), nullable=True, index=True)
recipient_tg_id = Column(BigInteger, ForeignKey("users.tg_id"), nullable=True, index=True)
selected_months = Column(Integer)
expiry_time = Column(DateTime)
gift_link = Column(String)
created_at = Column(DateTime, default=datetime.utcnow)
is_used = Column(Boolean, default=False)
is_unlimited = Column(Boolean, default=False)
max_usages = Column(Integer, nullable=True)
tariff_id: Mapped[int | None] = mapped_column(ForeignKey("tariffs.id"))
selected_device_limit = Column(Integer, nullable=True)
selected_traffic_gb = Column(Integer, nullable=True)
selected_price_rub = Column(Integer, nullable=True)
class GiftUsage(DictLikeMixin, Base):
__tablename__ = "gift_usages"
gift_id = Column(String, ForeignKey("gifts.gift_id"), primary_key=True)
user_id = Column(BigInteger, nullable=False, primary_key=True)
tg_id = Column(BigInteger, nullable=True, index=True)
used_at = Column(DateTime, default=datetime.utcnow)
+31
View File
@@ -0,0 +1,31 @@
import uuid
from datetime import datetime
from sqlalchemy import (
BigInteger,
Boolean,
Column,
DateTime,
String,
text as sql_text,
)
from ._base import Base, DictLikeMixin
class Identity(DictLikeMixin, Base):
"""Слой идентификации: к одному identity можно привязать email и/или Telegram (tg_id)."""
__tablename__ = "identities"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
email = Column(String(255), unique=True, nullable=True, index=True)
tg_id = Column(BigInteger, unique=True, nullable=True, index=True)
api_token_hash = Column(String(64), nullable=True, index=True)
token_issued_at = Column(DateTime, nullable=True)
password_hash = Column(String(64), nullable=True)
email_verified = Column(Boolean, nullable=False, server_default=sql_text("false"))
is_admin = Column(Boolean, nullable=False, server_default=sql_text("false"))
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+29
View File
@@ -0,0 +1,29 @@
from sqlalchemy import BigInteger, Boolean, Column, ForeignKey, Integer, String
from ._base import Base, DictLikeMixin
class Key(DictLikeMixin, Base):
__tablename__ = "keys"
tg_id = Column(BigInteger, ForeignKey("users.tg_id"), primary_key=True, nullable=False, index=True)
user_id = Column(BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=True, index=True)
client_id = Column(String, primary_key=True)
email = Column(String, unique=True)
created_at = Column(BigInteger)
expiry_time = Column(BigInteger)
key = Column(String)
server_id = Column(String)
remnawave_link = Column(String)
tariff_id = Column(Integer, ForeignKey("tariffs.id", ondelete="SET NULL"))
is_frozen = Column(Boolean, default=False)
alias = Column(String)
notified = Column(Boolean, default=False)
notified_24h = Column(Boolean, default=False)
selected_device_limit = Column(Integer, nullable=True)
selected_traffic_limit = Column(BigInteger, nullable=True)
selected_price_rub = Column(Integer, nullable=True)
current_device_limit = Column(Integer, nullable=True)
current_traffic_limit = Column(BigInteger, nullable=True)
+55
View File
@@ -0,0 +1,55 @@
import uuid
from datetime import UTC, datetime
from sqlalchemy import (
BigInteger,
Column,
DateTime,
ForeignKey,
Index,
Integer,
String,
Text,
text as sql_text,
)
from sqlalchemy.dialects.postgresql import JSONB
from ._base import Base, DictLikeMixin
class Notification(DictLikeMixin, Base):
__tablename__ = "notifications"
tg_id = Column(BigInteger, ForeignKey("users.tg_id", ondelete="CASCADE"), nullable=True, index=True)
user_id = Column(BigInteger, nullable=False, primary_key=True)
notification_type = Column(String, primary_key=True)
last_notification_time = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
class ScheduledBroadcast(DictLikeMixin, Base):
__tablename__ = "scheduled_broadcasts"
__table_args__ = (
Index("ix_scheduled_broadcasts_status_time", "status", "scheduled_for"),
Index("ix_scheduled_broadcasts_creator_time", "created_by_tg_id", "created_at"),
)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
created_by_user_id = Column(BigInteger, nullable=True, index=True)
created_by_tg_id = Column(BigInteger, ForeignKey("users.tg_id", ondelete="SET NULL"), nullable=True, index=True)
status = Column(String(32), nullable=False, server_default=sql_text("'scheduled'"), index=True)
send_to = Column(String(32), nullable=False, index=True)
cluster_name = Column(String, nullable=True)
text = Column(Text, nullable=False)
photo = Column(String, nullable=True)
keyboard_json = Column(JSONB, nullable=True)
scheduled_for = Column(DateTime(timezone=True), nullable=False, index=True)
workers = Column(Integer, nullable=False, server_default=sql_text("5"))
messages_per_second = Column(Integer, nullable=False, server_default=sql_text("35"))
stats_json = Column(JSONB, nullable=True)
error_text = Column(Text, nullable=True)
started_at = Column(DateTime(timezone=True), nullable=True)
sent_at = Column(DateTime(timezone=True), nullable=True)
cancelled_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+22
View File
@@ -0,0 +1,22 @@
from datetime import datetime
from sqlalchemy import BigInteger, Column, DateTime, Float, ForeignKey, Integer, Numeric, String
from sqlalchemy.dialects.postgresql import JSONB
from ._base import Base, DictLikeMixin
class Payment(DictLikeMixin, Base):
__tablename__ = "payments"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=True, index=True)
tg_id = Column(BigInteger, ForeignKey("users.tg_id"), nullable=True, index=True)
amount = Column(Float)
payment_system = Column(String)
status = Column(String)
created_at = Column(DateTime, default=datetime.utcnow)
original_amount = Column(Numeric(18, 8), nullable=True)
currency = Column(String(10), nullable=False, server_default="RUB")
payment_id = Column(String(128), nullable=True, index=True)
metadata_ = Column("metadata", JSONB, nullable=True)
+13
View File
@@ -0,0 +1,13 @@
from sqlalchemy import BigInteger, Boolean, Column, ForeignKey
from ._base import Base, DictLikeMixin
class Referral(DictLikeMixin, Base):
__tablename__ = "referrals"
referred_user_id = Column(BigInteger, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True)
referrer_user_id = Column(BigInteger, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True)
referred_tg_id = Column(BigInteger, nullable=True, index=True)
referrer_tg_id = Column(BigInteger, nullable=True, index=True)
reward_issued = Column(Boolean, default=False)
+47
View File
@@ -0,0 +1,47 @@
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, UniqueConstraint
from sqlalchemy.orm import relationship
from ._base import Base, DictLikeMixin
class Server(DictLikeMixin, Base):
__tablename__ = "servers"
id = Column(Integer, primary_key=True, autoincrement=True)
cluster_name = Column(String)
server_name = Column(String, unique=True)
api_url = Column(String)
subscription_url = Column(String)
inbound_id = Column(String)
panel_type = Column(String)
max_keys = Column(Integer)
tariff_group = Column(String)
enabled = Column(Boolean, default=True)
subgroups = relationship("ServerSubgroup", back_populates="server", cascade="all, delete-orphan")
groups = relationship("ServerSpecialgroup", back_populates="server", cascade="all, delete-orphan")
class ServerSubgroup(DictLikeMixin, Base):
__tablename__ = "server_subgroups"
id = Column(Integer, primary_key=True, autoincrement=True)
server_id = Column(Integer, ForeignKey("servers.id", ondelete="CASCADE"), index=True, nullable=False)
group_code = Column(String, nullable=False)
subgroup_title = Column(String, nullable=False)
server = relationship("Server", back_populates="subgroups")
__table_args__ = (UniqueConstraint("server_id", "subgroup_title", name="uq_server_subgroup"),)
class ServerSpecialgroup(DictLikeMixin, Base):
__tablename__ = "server_specialgroups"
id = Column(Integer, primary_key=True, autoincrement=True)
server_id = Column(Integer, ForeignKey("servers.id", ondelete="CASCADE"), index=True, nullable=False)
group_code = Column(String, nullable=False)
server = relationship("Server")
__table_args__ = (UniqueConstraint("server_id", "group_code", name="uq_server_group"),)

Some files were not shown because too many files have changed in this diff Show More