WEB-APP/ Optimization/ Build fix/ Hotkey edit mode/ Log rotation/ Form a11y/ E2E non-blocking
This commit is contained in:
+21
-9
@@ -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*
|
||||
|
||||
@@ -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
@@ -1,14 +1,16 @@
|
||||
import hashlib
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from fastapi import Depends, HTTPException, Header, Query, Request
|
||||
from fastapi import Depends, HTTPException, Header, Query, Request, Response
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from audit import set_api_actor
|
||||
from database import async_session_maker, identities as idb
|
||||
from database.models import Admin
|
||||
from database.access.resolution import ResolvedActor, resolve_actor_from_identity
|
||||
from database.models import Admin, Identity
|
||||
|
||||
|
||||
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
@@ -25,6 +27,24 @@ def hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
async def bind_identity_actor(
|
||||
request: Request | None,
|
||||
session: AsyncSession,
|
||||
identity: Identity,
|
||||
) -> ResolvedActor:
|
||||
actor = await resolve_actor_from_identity(session, identity)
|
||||
set_api_actor(request, identity_id=actor.identity_id, tg_id=actor.telegram_chat_id)
|
||||
if request is not None:
|
||||
request.state.actor = actor
|
||||
return actor
|
||||
|
||||
|
||||
def get_request_actor(request: Request | None) -> ResolvedActor | None:
|
||||
if request is None:
|
||||
return None
|
||||
return getattr(request.state, "actor", None)
|
||||
|
||||
|
||||
async def verify_admin_token(
|
||||
admin_id: int = Query(..., alias="tg_id"),
|
||||
token: str = Header(..., alias="X-Token"),
|
||||
@@ -40,50 +60,146 @@ async def verify_admin_token(
|
||||
return admin
|
||||
|
||||
|
||||
AUTH_COOKIE_NAME = "auth_token"
|
||||
|
||||
|
||||
IS_ADMIN_COOKIE_NAME = "is_admin"
|
||||
|
||||
|
||||
AUTH_COOKIE_MAX_AGE_SECONDS = 30 * 24 * 60 * 60
|
||||
|
||||
|
||||
def _is_secure_request(request: Request | None) -> bool:
|
||||
if request is None:
|
||||
return False
|
||||
if request.url.scheme == "https":
|
||||
return True
|
||||
forwarded_proto = request.headers.get("x-forwarded-proto", "").lower()
|
||||
return forwarded_proto == "https"
|
||||
|
||||
|
||||
def set_auth_cookie(response: Response, token: str, request: Request | None = None) -> None:
|
||||
"""Устанавливает HttpOnly cookie с auth-токеном на ответ. Используется во всех login-ручках."""
|
||||
response.set_cookie(
|
||||
key=AUTH_COOKIE_NAME,
|
||||
value=token,
|
||||
max_age=AUTH_COOKIE_MAX_AGE_SECONDS,
|
||||
path="/",
|
||||
httponly=True,
|
||||
secure=_is_secure_request(request),
|
||||
samesite="lax",
|
||||
)
|
||||
|
||||
|
||||
def clear_auth_cookie(response: Response, request: Request | None = None) -> None:
|
||||
"""Удаляет auth cookie на стороне браузера."""
|
||||
response.delete_cookie(
|
||||
key=AUTH_COOKIE_NAME,
|
||||
path="/",
|
||||
httponly=True,
|
||||
secure=_is_secure_request(request),
|
||||
samesite="lax",
|
||||
)
|
||||
|
||||
clear_is_admin_cookie(response, request)
|
||||
|
||||
|
||||
def set_is_admin_cookie(response: Response, identity: Identity, request: Request | None = None) -> None:
|
||||
"""Ставит/гасит `is_admin` cookie в зависимости от текущей identity."""
|
||||
if getattr(identity, "is_admin", False):
|
||||
response.set_cookie(
|
||||
key=IS_ADMIN_COOKIE_NAME,
|
||||
value="1",
|
||||
max_age=AUTH_COOKIE_MAX_AGE_SECONDS,
|
||||
path="/",
|
||||
httponly=True,
|
||||
secure=_is_secure_request(request),
|
||||
samesite="lax",
|
||||
)
|
||||
else:
|
||||
clear_is_admin_cookie(response, request)
|
||||
|
||||
|
||||
def clear_is_admin_cookie(response: Response, request: Request | None = None) -> None:
|
||||
response.delete_cookie(
|
||||
key=IS_ADMIN_COOKIE_NAME,
|
||||
path="/",
|
||||
httponly=True,
|
||||
secure=_is_secure_request(request),
|
||||
samesite="lax",
|
||||
)
|
||||
|
||||
|
||||
def _read_auth_cookie(request: Request | None) -> str | None:
|
||||
if request is None:
|
||||
return None
|
||||
raw = request.cookies.get(AUTH_COOKIE_NAME)
|
||||
if not raw:
|
||||
return None
|
||||
raw = raw.strip()
|
||||
return raw or None
|
||||
|
||||
|
||||
async def _identity_from_cookie(session: AsyncSession, request: Request | None) -> Identity | None:
|
||||
token = _read_auth_cookie(request)
|
||||
if not token:
|
||||
return None
|
||||
token_hash = hash_token(token)
|
||||
identity = await idb.get_identity_by_token_hash(session, token_hash)
|
||||
if identity is None:
|
||||
return None
|
||||
if idb._is_token_expired(identity):
|
||||
return None
|
||||
return identity
|
||||
|
||||
|
||||
async def verify_identity_token(
|
||||
x_identity_id: str = Header(..., alias="X-Identity-Id"),
|
||||
token: str = Header(..., alias="X-Token"),
|
||||
request: Request = None,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Проверяет пару identity_id + token; возвращает Identity. Для использования в API v2."""
|
||||
identity = await idb.verify_identity_token(session, x_identity_id, token)
|
||||
if not identity:
|
||||
"""Проверяет токен из HttpOnly cookie `auth_token`; возвращает Identity."""
|
||||
identity = await _identity_from_cookie(session, request)
|
||||
if identity is None:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
set_api_actor(request, identity_id=identity.id, tg_id=identity.tg_id)
|
||||
await bind_identity_actor(request, session, identity)
|
||||
return identity
|
||||
|
||||
|
||||
async def verify_identity_admin(
|
||||
x_identity_id: str = Header(..., alias="X-Identity-Id"),
|
||||
token: str = Header(..., alias="X-Token"),
|
||||
request: Request = None,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Проверяет identity + token и что identity.is_admin; для админских ручек v2."""
|
||||
identity = await idb.verify_identity_token(session, x_identity_id, token)
|
||||
if not identity:
|
||||
"""Проверяет токен из cookie и что identity.is_admin; для админских ручек v2."""
|
||||
identity = await _identity_from_cookie(session, request)
|
||||
if identity is None:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
if not identity.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
set_api_actor(request, identity_id=identity.id, tg_id=identity.tg_id)
|
||||
await bind_identity_actor(request, session, identity)
|
||||
return identity
|
||||
|
||||
|
||||
async def verify_identity_admin_short(
|
||||
x_identity_id: str = Header(..., alias="X-Identity-Id"),
|
||||
token: str = Header(..., alias="X-Token"),
|
||||
request: Request = None,
|
||||
request: Request,
|
||||
):
|
||||
"""Проверка админа с короткой сессией (для broadcast и др.), чтобы не держать соединение с БД."""
|
||||
identity = None
|
||||
actor = None
|
||||
async with async_session_maker() as session:
|
||||
identity = await idb.verify_identity_token(session, x_identity_id, token)
|
||||
identity = await _identity_from_cookie(session, request)
|
||||
if identity:
|
||||
actor = await resolve_actor_from_identity(session, identity)
|
||||
await session.commit()
|
||||
if not identity:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
if not identity.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
set_api_actor(request, identity_id=identity.id, tg_id=identity.tg_id)
|
||||
if actor is not None:
|
||||
set_api_actor(request, identity_id=identity.id, tg_id=actor.telegram_chat_id)
|
||||
if request is not None:
|
||||
request.state.actor = actor
|
||||
else:
|
||||
set_api_actor(request, identity_id=identity.id, tg_id=identity.tg_id)
|
||||
return identity
|
||||
|
||||
|
||||
@@ -102,3 +218,20 @@ async def verify_admin_token_short(
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
set_api_actor(request, tg_id=admin.tg_id)
|
||||
return admin
|
||||
|
||||
|
||||
def validate_redirect_url(url: str, base_url: str) -> str:
|
||||
"""Validate redirect URL is same-origin or relative. Returns safe URL or base_url fallback."""
|
||||
url = url.strip()
|
||||
if not url:
|
||||
return base_url
|
||||
if url.startswith("/"):
|
||||
return url
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
base_parsed = urlparse(base_url)
|
||||
if parsed.scheme in ("http", "https") and parsed.netloc == base_parsed.netloc:
|
||||
return url
|
||||
except Exception:
|
||||
pass
|
||||
return base_url
|
||||
|
||||
+24
-5
@@ -14,7 +14,8 @@ from logger import logger
|
||||
if API_VERSION == 1:
|
||||
from api.v1 import router as api_router, VERSION as API_DOC_VERSION
|
||||
else:
|
||||
from api.v2 import router as api_router, VERSION as API_DOC_VERSION
|
||||
from api.v2 import VERSION as API_DOC_VERSION
|
||||
from api.v2.router import router as api_router
|
||||
|
||||
app = FastAPI(
|
||||
title=f"SoloBot API (Alpha) — API v{API_DOC_VERSION}",
|
||||
@@ -25,15 +26,28 @@ app = FastAPI(
|
||||
openapi_url="/api/openapi.json",
|
||||
)
|
||||
|
||||
_cors_origins = API_CORS_ORIGINS if API_CORS_ORIGINS != ["*"] else API_CORS_ORIGINS
|
||||
_cors_credentials = API_CORS_ORIGINS != ["*"]
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=API_CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
allow_origins=_cors_origins,
|
||||
allow_credentials=_cors_credentials,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
|
||||
allow_headers=["X-Identity-Id", "X-Token", "Content-Type", "Authorization"],
|
||||
)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def security_headers_middleware(request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||
response.headers.setdefault("X-Frame-Options", "DENY")
|
||||
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
response.headers.setdefault("X-XSS-Protection", "1; mode=block")
|
||||
return response
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def api_access_log_middleware(request: Request, call_next):
|
||||
context = ensure_api_context(request)
|
||||
@@ -86,6 +100,11 @@ async def api_access_log_middleware(request: Request, call_next):
|
||||
return response
|
||||
|
||||
|
||||
@app.get("/api/health", include_in_schema=False)
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
app.include_router(api_router)
|
||||
|
||||
_web_uploads_dir = "static/web_uploads"
|
||||
|
||||
+58
-11
@@ -1,12 +1,22 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
|
||||
from sqlalchemy import inspect as sa_inspect
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
||||
|
||||
from api.depends import get_session, verify_admin_token
|
||||
from database.models import Admin
|
||||
from database.access.resolution import resolve_user_optional
|
||||
from handlers.texts import get_site_gift_link, get_telegram_gift_link
|
||||
|
||||
|
||||
def _apply_user_relationship_loader(model: type, stmt):
|
||||
if model.__name__ in ("ManualBan", "BlockedUser", "TemporaryData"):
|
||||
return stmt.options(selectinload(model.user))
|
||||
return stmt
|
||||
|
||||
|
||||
def cast_identifier_type(field: InstrumentedAttribute, value: int | str):
|
||||
@@ -19,6 +29,21 @@ def cast_identifier_type(field: InstrumentedAttribute, value: int | str):
|
||||
def normalize_outgoing_object(obj: object) -> None:
|
||||
if hasattr(obj, "vless") and getattr(obj, "vless") is None:
|
||||
setattr(obj, "vless", False)
|
||||
cls_name = type(obj).__name__
|
||||
if cls_name == "Gift":
|
||||
gift_id = getattr(obj, "gift_id", None)
|
||||
if gift_id:
|
||||
setattr(obj, "telegram_gift_link", get_telegram_gift_link(gift_id))
|
||||
setattr(obj, "site_gift_link", get_site_gift_link(gift_id))
|
||||
if cls_name in ("ManualBan", "BlockedUser", "TemporaryData"):
|
||||
insp = sa_inspect(obj)
|
||||
stored = getattr(obj, "tg_id", None)
|
||||
if "user" in insp.unloaded:
|
||||
setattr(obj, "tg_id", stored)
|
||||
return
|
||||
rel = getattr(obj, "user", None)
|
||||
rel_tg = getattr(rel, "tg_id", None) if rel is not None else None
|
||||
setattr(obj, "tg_id", stored if stored is not None else rel_tg)
|
||||
|
||||
|
||||
def to_schema(schema_response: type, obj: object):
|
||||
@@ -35,10 +60,20 @@ def generate_crud_router(
|
||||
identifier_field: str = "tg_id",
|
||||
parameter_name: str = "tg_id",
|
||||
extra_get_by_email: bool = False,
|
||||
telegram_path_to_user_id: bool = False,
|
||||
enabled_methods: list[str] = ("get_all", "get_one", "get_by_email", "create", "update", "delete"),
|
||||
) -> APIRouter:
|
||||
router = APIRouter()
|
||||
|
||||
async def _path_filter(session: AsyncSession, value: int | str):
|
||||
if telegram_path_to_user_id:
|
||||
u = await resolve_user_optional(session, int(value))
|
||||
if u is None:
|
||||
return None
|
||||
return getattr(model, "user_id"), u.id
|
||||
field = getattr(model, identifier_field)
|
||||
return field, cast_identifier_type(field, value)
|
||||
|
||||
if "get_all" in enabled_methods:
|
||||
|
||||
@router.get("/", response_model=list[schema_response])
|
||||
@@ -46,7 +81,7 @@ def generate_crud_router(
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(select(model))
|
||||
result = await session.execute(_apply_user_relationship_loader(model, select(model)))
|
||||
items = result.scalars().all()
|
||||
for item in items:
|
||||
normalize_outgoing_object(item)
|
||||
@@ -74,9 +109,13 @@ def generate_crud_router(
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
field = getattr(model, identifier_field)
|
||||
casted = cast_identifier_type(field, value)
|
||||
result = await session.execute(select(model).where(field == casted))
|
||||
resolved = await _path_filter(session, value)
|
||||
if resolved is None:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
field, casted = resolved
|
||||
result = await session.execute(
|
||||
_apply_user_relationship_loader(model, select(model).where(field == casted))
|
||||
)
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
@@ -90,9 +129,13 @@ def generate_crud_router(
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
field = getattr(model, identifier_field)
|
||||
casted = cast_identifier_type(field, value)
|
||||
result = await session.execute(select(model).where(field == casted))
|
||||
resolved = await _path_filter(session, value)
|
||||
if resolved is None:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
field, casted = resolved
|
||||
result = await session.execute(
|
||||
_apply_user_relationship_loader(model, select(model).where(field == casted))
|
||||
)
|
||||
objs = result.scalars().all()
|
||||
if not objs:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
@@ -127,8 +170,10 @@ def generate_crud_router(
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
field = getattr(model, identifier_field)
|
||||
casted = cast_identifier_type(field, value)
|
||||
resolved = await _path_filter(session, value)
|
||||
if resolved is None:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
field, casted = resolved
|
||||
result = await session.execute(select(model).where(field == casted))
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
@@ -150,8 +195,10 @@ def generate_crud_router(
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
field = getattr(model, identifier_field)
|
||||
casted = cast_identifier_type(field, value)
|
||||
resolved = await _path_filter(session, value)
|
||||
if resolved is None:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
field, casted = resolved
|
||||
result = await session.execute(select(model).where(field == casted))
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
|
||||
@@ -6,6 +6,7 @@ from api.depends import get_session, verify_admin_token
|
||||
from api.v1.routes.base_crud import generate_crud_router
|
||||
from api.v1.schemas import GiftBase, GiftResponse, GiftUpdate, GiftUsageResponse
|
||||
from database.models import Admin, Gift, GiftUsage
|
||||
from database.access.resolution import resolve_user_optional
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
@@ -29,7 +30,10 @@ async def get_gifts_by_tg_id(
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(select(Gift).where(Gift.sender_tg_id == tg_id))
|
||||
u = await resolve_user_optional(session, tg_id)
|
||||
if u is None:
|
||||
raise HTTPException(status_code=404, detail="Gifts not found")
|
||||
result = await session.execute(select(Gift).where(Gift.sender_user_id == u.id))
|
||||
gifts = result.scalars().all()
|
||||
if not gifts:
|
||||
raise HTTPException(status_code=404, detail="Gifts not found")
|
||||
|
||||
@@ -8,7 +8,8 @@ from api.depends import get_session, verify_admin_token
|
||||
from api.v1.routes.base_crud import generate_crud_router
|
||||
from api.v1.schemas.keys import KeyBase, KeyCreateRequest, KeyResponse, KeyUpdate
|
||||
from database.models import Admin, Key, Tariff
|
||||
from handlers.keys.operations import create_key_on_cluster, delete_key_from_cluster, renew_key_in_cluster
|
||||
from database.access.resolution import resolve_user_optional
|
||||
from services.operations import create_key_on_cluster, delete_key_from_cluster, renew_key_in_cluster
|
||||
from logger import logger
|
||||
|
||||
|
||||
@@ -63,7 +64,10 @@ async def get_router_keys_by_tg_id(
|
||||
if not tariff_ids:
|
||||
return []
|
||||
|
||||
keys_result = await session.execute(select(Key).where(Key.tg_id == tg_id, Key.tariff_id.in_(tariff_ids)))
|
||||
u = await resolve_user_optional(session, tg_id)
|
||||
if u is None:
|
||||
return []
|
||||
keys_result = await session.execute(select(Key).where(Key.user_id == u.id, Key.tariff_id.in_(tariff_ids)))
|
||||
keys = keys_result.scalars().all()
|
||||
return keys
|
||||
|
||||
|
||||
@@ -208,7 +208,7 @@ async def restore_trials(
|
||||
update(User)
|
||||
.where(
|
||||
User.trial == 1,
|
||||
~exists(select(Key.tg_id).where(Key.tg_id == User.tg_id)),
|
||||
~exists(select(Key.user_id).where(Key.user_id == User.id)),
|
||||
)
|
||||
.values(trial=0)
|
||||
)
|
||||
|
||||
+16
-5
@@ -13,6 +13,7 @@ from api.v1.schemas import (
|
||||
TrackingSourceResponse,
|
||||
)
|
||||
from database import get_tracking_source_stats
|
||||
from database.access.resolution import resolve_user_optional
|
||||
from database.models import (
|
||||
Admin,
|
||||
BlockedUser,
|
||||
@@ -47,7 +48,10 @@ async def get_payments_by_tg_id(
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(select(Payment).where(Payment.tg_id == tg_id))
|
||||
u = await resolve_user_optional(session, tg_id)
|
||||
if u is None:
|
||||
raise HTTPException(status_code=404, detail="Payments not found")
|
||||
result = await session.execute(select(Payment).where(Payment.user_id == u.id))
|
||||
payments = result.scalars().all()
|
||||
if not payments:
|
||||
raise HTTPException(status_code=404, detail="Payments not found")
|
||||
@@ -60,7 +64,8 @@ router.include_router(
|
||||
schema_response=NotificationResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="tg_id",
|
||||
identifier_field="user_id",
|
||||
telegram_path_to_user_id=True,
|
||||
enabled_methods=["get_all", "get_one", "delete"],
|
||||
),
|
||||
prefix="/notifications",
|
||||
@@ -75,7 +80,9 @@ router.include_router(
|
||||
schema_response=ManualBanResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="tg_id",
|
||||
identifier_field="user_id",
|
||||
parameter_name="tg_id",
|
||||
telegram_path_to_user_id=True,
|
||||
enabled_methods=["get_all", "get_one", "delete"],
|
||||
),
|
||||
prefix="/manual-bans",
|
||||
@@ -89,7 +96,9 @@ router.include_router(
|
||||
schema_response=BlockedUserResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="tg_id",
|
||||
identifier_field="user_id",
|
||||
parameter_name="tg_id",
|
||||
telegram_path_to_user_id=True,
|
||||
enabled_methods=["get_all", "get_one", "delete"],
|
||||
),
|
||||
prefix="/blocked-users",
|
||||
@@ -103,7 +112,9 @@ router.include_router(
|
||||
schema_response=TemporaryDataResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="tg_id",
|
||||
identifier_field="user_id",
|
||||
parameter_name="tg_id",
|
||||
telegram_path_to_user_id=True,
|
||||
enabled_methods=["get_all", "get_one", "delete"],
|
||||
),
|
||||
prefix="/temporary-data",
|
||||
|
||||
@@ -6,6 +6,7 @@ from api.depends import get_session, verify_admin_token
|
||||
from api.v1.routes.base_crud import generate_crud_router
|
||||
from api.v1.schemas import ReferralResponse
|
||||
from database.models import Admin, Referral
|
||||
from database.access.resolution import resolve_user_optional
|
||||
|
||||
|
||||
router = generate_crud_router(
|
||||
@@ -13,7 +14,9 @@ router = generate_crud_router(
|
||||
schema_response=ReferralResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="referrer_tg_id",
|
||||
identifier_field="referrer_user_id",
|
||||
parameter_name="referrer_tg_id",
|
||||
telegram_path_to_user_id=True,
|
||||
enabled_methods=["get_all", "get_one", "get_all_by_field"],
|
||||
)
|
||||
|
||||
@@ -25,8 +28,15 @@ async def delete_one_referral(
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
ru_ref = await resolve_user_optional(session, referrer_tg_id)
|
||||
rd_ref = await resolve_user_optional(session, referred_tg_id)
|
||||
if ru_ref is None or rd_ref is None:
|
||||
raise HTTPException(status_code=404, detail="Referral not found")
|
||||
result = await session.execute(
|
||||
select(Referral).where(Referral.referrer_tg_id == referrer_tg_id, Referral.referred_tg_id == referred_tg_id)
|
||||
select(Referral).where(
|
||||
Referral.referrer_user_id == ru_ref.id,
|
||||
Referral.referred_user_id == rd_ref.id,
|
||||
)
|
||||
)
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
|
||||
@@ -9,7 +9,8 @@ from api.v1.routes.base_crud import generate_crud_router
|
||||
from api.v1.schemas.users import UserBase, UserResponse, UserUpdate
|
||||
from database import async_session_maker, delete_user_data, get_servers
|
||||
from database.models import Key, User
|
||||
from handlers.keys.operations import delete_key_from_cluster
|
||||
from database.access.resolution import resolve_user_optional
|
||||
from services.operations import delete_key_from_cluster
|
||||
from logger import logger
|
||||
|
||||
|
||||
@@ -30,7 +31,10 @@ async def delete_user(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
result = await session.execute(select(Key.email, Key.client_id).where(Key.tg_id == tg_id))
|
||||
u = await resolve_user_optional(session, tg_id)
|
||||
if u is None:
|
||||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||||
result = await session.execute(select(Key.email, Key.client_id).where(Key.user_id == u.id))
|
||||
key_records = result.all()
|
||||
|
||||
async with async_session_maker() as s:
|
||||
|
||||
@@ -4,11 +4,13 @@ from pydantic import BaseModel
|
||||
|
||||
|
||||
class GiftBase(BaseModel):
|
||||
sender_tg_id: int
|
||||
recipient_tg_id: int | None = None
|
||||
sender_user_id: int
|
||||
recipient_user_id: int | None = None
|
||||
selected_months: int | None = None
|
||||
expiry_time: datetime
|
||||
gift_link: str
|
||||
telegram_gift_link: str | None = None
|
||||
site_gift_link: str | None = None
|
||||
is_used: bool = False
|
||||
is_unlimited: bool | None = False
|
||||
max_usages: int | None = None
|
||||
@@ -33,10 +35,12 @@ class GiftUsageResponse(BaseModel):
|
||||
|
||||
|
||||
class GiftUpdate(BaseModel):
|
||||
recipient_tg_id: int | None = None
|
||||
recipient_user_id: int | None = None
|
||||
selected_months: int | None = None
|
||||
expiry_time: datetime | None = None
|
||||
gift_link: str | None = None
|
||||
telegram_gift_link: str | None = None
|
||||
site_gift_link: str | None = None
|
||||
is_used: bool | None = None
|
||||
is_unlimited: bool | None = None
|
||||
max_usages: int | None = None
|
||||
|
||||
@@ -2,8 +2,9 @@ from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class KeyBase(BaseModel):
|
||||
tg_id: int
|
||||
user_id: int
|
||||
client_id: str
|
||||
tg_id: int | None = None
|
||||
email: str | None = None
|
||||
created_at: int | None = None
|
||||
expiry_time: int
|
||||
|
||||
+14
-8
@@ -4,7 +4,8 @@ from pydantic import BaseModel
|
||||
|
||||
|
||||
class PaymentBase(BaseModel):
|
||||
tg_id: int
|
||||
user_id: int
|
||||
tg_id: int | None = None
|
||||
amount: float
|
||||
payment_system: str
|
||||
status: str
|
||||
@@ -19,8 +20,8 @@ class PaymentResponse(PaymentBase):
|
||||
|
||||
|
||||
class ReferralResponse(BaseModel):
|
||||
referred_tg_id: int
|
||||
referrer_tg_id: int
|
||||
referred_user_id: int
|
||||
referrer_user_id: int
|
||||
reward_issued: bool = False
|
||||
|
||||
class Config:
|
||||
@@ -37,11 +38,13 @@ class NotificationResponse(BaseModel):
|
||||
|
||||
|
||||
class GiftBase(BaseModel):
|
||||
sender_tg_id: int
|
||||
recipient_tg_id: int | None = None
|
||||
sender_user_id: int
|
||||
recipient_user_id: int | None = None
|
||||
selected_months: int
|
||||
expiry_time: datetime
|
||||
gift_link: str
|
||||
telegram_gift_link: str | None = None
|
||||
site_gift_link: str | None = None
|
||||
is_used: bool = False
|
||||
is_unlimited: bool = False
|
||||
max_usages: int | None = None
|
||||
@@ -66,7 +69,8 @@ class GiftUsageResponse(BaseModel):
|
||||
|
||||
|
||||
class ManualBanResponse(BaseModel):
|
||||
tg_id: int
|
||||
user_id: int
|
||||
tg_id: int | None = None
|
||||
banned_at: datetime
|
||||
reason: str
|
||||
banned_by: int
|
||||
@@ -77,7 +81,8 @@ class ManualBanResponse(BaseModel):
|
||||
|
||||
|
||||
class TemporaryDataResponse(BaseModel):
|
||||
tg_id: int
|
||||
user_id: int
|
||||
tg_id: int | None = None
|
||||
state: str
|
||||
data: dict
|
||||
updated_at: datetime
|
||||
@@ -87,7 +92,8 @@ class TemporaryDataResponse(BaseModel):
|
||||
|
||||
|
||||
class BlockedUserResponse(BaseModel):
|
||||
tg_id: int
|
||||
user_id: int
|
||||
tg_id: int | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@@ -2,8 +2,8 @@ from pydantic import BaseModel
|
||||
|
||||
|
||||
class ReferralResponse(BaseModel):
|
||||
referred_tg_id: int
|
||||
referrer_tg_id: int
|
||||
referred_user_id: int
|
||||
referrer_user_id: int
|
||||
reward_issued: bool = False
|
||||
|
||||
class Config:
|
||||
|
||||
+7
-3
@@ -1,5 +1,9 @@
|
||||
from api.v2.router import router
|
||||
|
||||
VERSION = "2.0.0"
|
||||
|
||||
__all__ = ("router", "VERSION")
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
if name == "router":
|
||||
from api.v2.router import router
|
||||
return router
|
||||
raise AttributeError(name)
|
||||
|
||||
+40
-12
@@ -6,7 +6,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
||||
|
||||
from api.depends import get_session, verify_identity_admin
|
||||
from api.v1.routes.base_crud import cast_identifier_type, normalize_outgoing_object, to_schema
|
||||
from api.v1.routes.base_crud import (
|
||||
_apply_user_relationship_loader,
|
||||
cast_identifier_type,
|
||||
normalize_outgoing_object,
|
||||
to_schema,
|
||||
)
|
||||
from database.access.resolution import resolve_user_optional
|
||||
|
||||
|
||||
def generate_crud_router(
|
||||
@@ -18,10 +24,20 @@ def generate_crud_router(
|
||||
identifier_field: str = "tg_id",
|
||||
parameter_name: str = "tg_id",
|
||||
extra_get_by_email: bool = False,
|
||||
telegram_path_to_user_id: bool = False,
|
||||
enabled_methods: list[str] = ("get_all", "get_one", "get_by_email", "create", "update", "delete"),
|
||||
) -> APIRouter:
|
||||
router = APIRouter()
|
||||
|
||||
async def _path_filter(session: AsyncSession, value: int | str):
|
||||
if telegram_path_to_user_id:
|
||||
u = await resolve_user_optional(session, int(value))
|
||||
if u is None:
|
||||
return None
|
||||
return getattr(model, "user_id"), u.id
|
||||
field = getattr(model, identifier_field)
|
||||
return field, cast_identifier_type(field, value)
|
||||
|
||||
if "get_all" in enabled_methods:
|
||||
|
||||
@router.get("/", response_model=list[schema_response])
|
||||
@@ -29,7 +45,7 @@ def generate_crud_router(
|
||||
identity=Depends(verify_identity_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(select(model))
|
||||
result = await session.execute(_apply_user_relationship_loader(model, select(model)))
|
||||
items = result.scalars().all()
|
||||
for item in items:
|
||||
normalize_outgoing_object(item)
|
||||
@@ -57,9 +73,13 @@ def generate_crud_router(
|
||||
identity=Depends(verify_identity_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
field = getattr(model, identifier_field)
|
||||
casted = cast_identifier_type(field, value)
|
||||
result = await session.execute(select(model).where(field == casted))
|
||||
resolved = await _path_filter(session, value)
|
||||
if resolved is None:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
field, casted = resolved
|
||||
result = await session.execute(
|
||||
_apply_user_relationship_loader(model, select(model).where(field == casted))
|
||||
)
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
@@ -73,9 +93,13 @@ def generate_crud_router(
|
||||
identity=Depends(verify_identity_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
field = getattr(model, identifier_field)
|
||||
casted = cast_identifier_type(field, value)
|
||||
result = await session.execute(select(model).where(field == casted))
|
||||
resolved = await _path_filter(session, value)
|
||||
if resolved is None:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
field, casted = resolved
|
||||
result = await session.execute(
|
||||
_apply_user_relationship_loader(model, select(model).where(field == casted))
|
||||
)
|
||||
objs = result.scalars().all()
|
||||
if not objs:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
@@ -110,8 +134,10 @@ def generate_crud_router(
|
||||
identity=Depends(verify_identity_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
field = getattr(model, identifier_field)
|
||||
casted = cast_identifier_type(field, value)
|
||||
resolved = await _path_filter(session, value)
|
||||
if resolved is None:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
field, casted = resolved
|
||||
result = await session.execute(select(model).where(field == casted))
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
@@ -131,8 +157,10 @@ def generate_crud_router(
|
||||
identity=Depends(verify_identity_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
field = getattr(model, identifier_field)
|
||||
casted = cast_identifier_type(field, value)
|
||||
resolved = await _path_filter(session, value)
|
||||
if resolved is None:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
field, casted = resolved
|
||||
result = await session.execute(select(model).where(field == casted))
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
|
||||
+8
-2
@@ -18,6 +18,8 @@ from api.v2.routes import (
|
||||
payment_links,
|
||||
identities,
|
||||
web,
|
||||
flows,
|
||||
notifications,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
@@ -25,10 +27,12 @@ router = APIRouter()
|
||||
router.include_router(root_router)
|
||||
router.include_router(auth.router, prefix="/api")
|
||||
router.include_router(users.router, prefix="/api/users", tags=["Users"])
|
||||
router.include_router(keys.router, prefix="/api/keys", tags=["Keys"])
|
||||
router.include_router(keys.user_router, prefix="/api/keys", tags=["Keys"])
|
||||
router.include_router(keys.router, prefix="/api/admin/keys", tags=["AdminKeys"])
|
||||
router.include_router(coupons.router, prefix="/api/coupons", tags=["Coupons"])
|
||||
router.include_router(servers.router, prefix="/api/servers", tags=["Servers"])
|
||||
router.include_router(tariffs.public_router, prefix="/api/tariffs", tags=["Tariffs"])
|
||||
router.include_router(tariffs.user_tariff_router, prefix="/api/tariffs", tags=["Tariffs"])
|
||||
router.include_router(tariffs.router, prefix="/api/tariffs", tags=["Tariffs"])
|
||||
router.include_router(gifts.router, prefix="/api/gifts", tags=["Gifts"])
|
||||
router.include_router(referrals.router, prefix="/api/referrals", tags=["Referrals"])
|
||||
@@ -39,4 +43,6 @@ router.include_router(misc.router, prefix="/api")
|
||||
router.include_router(modules.router, prefix="/api")
|
||||
router.include_router(management.router, prefix="/api/management", tags=["Management"])
|
||||
router.include_router(settings.router, prefix="/api/settings", tags=["Settings"])
|
||||
router.include_router(web.router, prefix="", tags=["Web"])
|
||||
router.include_router(web.router, prefix="", tags=["Web"])
|
||||
router.include_router(flows.router, prefix="/api", tags=["Flows"])
|
||||
router.include_router(notifications.router, prefix="/api", tags=["Notifications"])
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
from api.v2.routes.root import router as root_router
|
||||
|
||||
__all__ = ("root_router",)
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
if name == "root_router":
|
||||
from api.v2.routes.root import router
|
||||
|
||||
return router
|
||||
raise AttributeError(name)
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from audit import set_api_actor
|
||||
from api.depends import get_session, verify_identity_token
|
||||
from api.v2.schemas.identities import (
|
||||
IdentityResponse,
|
||||
LinkTelegramRequest,
|
||||
LoginByCodeRequest,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
LoginTelegramRequest,
|
||||
RegisterByEmailRequest,
|
||||
RegisterResponse,
|
||||
SendLoginCodeRequest,
|
||||
)
|
||||
from config import API_TOKEN_TTL_DAYS, API_TOKEN
|
||||
from database import identities as idb
|
||||
from utils.telegram_login import verify_telegram_login
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["Auth"])
|
||||
TOKEN_TTL_HINT = "бессрочно" if API_TOKEN_TTL_DAYS is None else f"{API_TOKEN_TTL_DAYS} дн."
|
||||
TELEGRAM_LOGIN_MAX_AGE = 86400
|
||||
|
||||
|
||||
@router.post("/register", response_model=RegisterResponse)
|
||||
async def register_by_email(
|
||||
body: RegisterByEmailRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
(
|
||||
"""Регистрация по почте и паролю: создаётся идентичность, выдаётся токен. Срок действия токена: """
|
||||
+ TOKEN_TTL_HINT
|
||||
+ "."
|
||||
)
|
||||
email = body.email.strip().lower()
|
||||
if not email:
|
||||
raise HTTPException(status_code=400, detail="Email обязателен")
|
||||
if not body.password or len(body.password) < 8:
|
||||
raise HTTPException(status_code=400, detail="Пароль минимум 8 символов")
|
||||
existing = await idb.get_identity_by_email(session, email)
|
||||
if existing:
|
||||
raise HTTPException(status_code=409, detail="Идентичность с таким email уже существует")
|
||||
identity, token = await idb.create_identity_with_token(session, email=email, password=body.password)
|
||||
set_api_actor(request, identity_id=identity.id, tg_id=identity.tg_id)
|
||||
return RegisterResponse(identity_id=identity.id, token=token)
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
async def login(
|
||||
body: LoginRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Вход по email и паролю. Возвращает identity_id и новый токен. Срок действия токена: """ + TOKEN_TTL_HINT + "."
|
||||
email = body.email.strip().lower()
|
||||
if not email:
|
||||
raise HTTPException(status_code=400, detail="Email обязателен")
|
||||
result = await idb.login_by_email(session, email, body.password)
|
||||
if not result:
|
||||
raise HTTPException(status_code=401, detail="Неверный email или пароль")
|
||||
identity, token = result
|
||||
set_api_actor(request, identity_id=identity.id, tg_id=identity.tg_id)
|
||||
return LoginResponse(identity_id=identity.id, token=token)
|
||||
|
||||
|
||||
_LOGIN_CODES: dict[str, tuple[str, float]] = {}
|
||||
_LOGIN_CODE_TTL = 600.0
|
||||
|
||||
|
||||
def _clean_login_codes() -> None:
|
||||
import time
|
||||
now = time.time()
|
||||
for k in list(_LOGIN_CODES):
|
||||
if now - _LOGIN_CODES[k][1] > _LOGIN_CODE_TTL:
|
||||
del _LOGIN_CODES[k]
|
||||
|
||||
|
||||
@router.post("/send-login-code")
|
||||
async def send_login_code(
|
||||
body: SendLoginCodeRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Отправить код входа на email. Код хранится на сервере 10 мин (для демо — без реальной отправки письма)."""
|
||||
_clean_login_codes()
|
||||
email = body.email.strip().lower()
|
||||
if not email:
|
||||
raise HTTPException(status_code=400, detail="Email обязателен")
|
||||
identity = await idb.get_identity_by_email(session, email)
|
||||
if not identity:
|
||||
raise HTTPException(status_code=404, detail="Аккаунт с таким email не найден")
|
||||
import secrets
|
||||
import time
|
||||
code = "".join(secrets.choice("0123456789") for _ in range(6))
|
||||
_LOGIN_CODES[email] = (code, time.time())
|
||||
return {"ok": True, "message": "Код отправлен на почту"}
|
||||
|
||||
|
||||
@router.post("/login-by-code", response_model=LoginResponse)
|
||||
async def login_by_code(
|
||||
body: LoginByCodeRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Вход по email и коду из письма."""
|
||||
_clean_login_codes()
|
||||
email = body.email.strip().lower()
|
||||
if not email or not body.code or not body.code.strip():
|
||||
raise HTTPException(status_code=400, detail="Email и код обязательны")
|
||||
stored = _LOGIN_CODES.get(email)
|
||||
if not stored:
|
||||
raise HTTPException(status_code=400, detail="Код не найден или истёк. Запросите новый.")
|
||||
code_value, _ = stored
|
||||
if body.code.strip() != code_value:
|
||||
raise HTTPException(status_code=401, detail="Неверный код")
|
||||
del _LOGIN_CODES[email]
|
||||
identity = await idb.get_identity_by_email(session, email)
|
||||
if not identity:
|
||||
raise HTTPException(status_code=401, detail="Аккаунт не найден")
|
||||
token = await idb.issue_token_for_identity(session, identity)
|
||||
set_api_actor(request, identity_id=identity.id, tg_id=identity.tg_id)
|
||||
return LoginResponse(identity_id=identity.id, token=token)
|
||||
|
||||
|
||||
@router.post("/login-telegram", response_model=LoginResponse)
|
||||
async def login_telegram(
|
||||
body: LoginTelegramRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
(
|
||||
"""Вход через Telegram Login Widget (кнопка на сайте). По tg_id находим или создаём Identity, выдаём токен. Срок действия токена: """
|
||||
+ TOKEN_TTL_HINT
|
||||
+ "."
|
||||
)
|
||||
payload = body.model_dump(mode="json")
|
||||
if not verify_telegram_login(payload, API_TOKEN, max_age_seconds=TELEGRAM_LOGIN_MAX_AGE):
|
||||
raise HTTPException(status_code=401, detail="Неверная подпись или устаревшие данные от Telegram")
|
||||
identity = await idb.get_or_create_identity_for_tg(session, body.id)
|
||||
token = await idb.issue_token_for_identity(session, identity)
|
||||
set_api_actor(request, identity_id=identity.id, tg_id=identity.tg_id)
|
||||
return LoginResponse(identity_id=identity.id, token=token)
|
||||
|
||||
|
||||
@router.post("/link-telegram", response_model=IdentityResponse)
|
||||
async def link_telegram(
|
||||
body: LinkTelegramRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
"""Привязывает Telegram к текущей идентичности. Требуется подпись от Telegram Login Widget (доказательство владения аккаунтом)."""
|
||||
payload = body.model_dump(mode="json")
|
||||
if not verify_telegram_login(payload, API_TOKEN, max_age_seconds=TELEGRAM_LOGIN_MAX_AGE):
|
||||
raise HTTPException(status_code=401, detail="Неверная подпись или устаревшие данные от Telegram")
|
||||
result = await idb.attach_telegram(session, identity.id, body.id)
|
||||
if not result:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Этот Telegram уже привязан к другой идентичности",
|
||||
)
|
||||
set_api_actor(request, identity_id=result.id, tg_id=result.tg_id)
|
||||
return IdentityResponse.model_validate(result)
|
||||
|
||||
|
||||
@router.get("/me", response_model=IdentityResponse)
|
||||
async def me(
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
"""Текущая идентичность по заголовкам X-Identity-Id и X-Token."""
|
||||
return IdentityResponse.model_validate(identity)
|
||||
@@ -0,0 +1,13 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from api.v2.routes.auth import email_verify, link, password, session, telegram
|
||||
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["Auth"])
|
||||
router.include_router(password.router)
|
||||
router.include_router(telegram.router)
|
||||
router.include_router(link.router)
|
||||
router.include_router(email_verify.router)
|
||||
router.include_router(session.router)
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -0,0 +1,112 @@
|
||||
from fastapi import Request
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import API_TOKEN_TTL_DAYS
|
||||
from logger import logger
|
||||
from utils.referral_codes import encode_partner_code
|
||||
|
||||
|
||||
TOKEN_TTL_HINT = "бессрочно" if API_TOKEN_TTL_DAYS is None else f"{API_TOKEN_TTL_DAYS} дн."
|
||||
TELEGRAM_LOGIN_MAX_AGE = 86400
|
||||
|
||||
_TRUSTED_PROXY_CIDRS: list[str] = []
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
client_host = (request.client.host if request.client else "") or ""
|
||||
forwarded = request.headers.get("x-forwarded-for") or request.headers.get("X-Forwarded-For")
|
||||
if not forwarded:
|
||||
return client_host
|
||||
if not _TRUSTED_PROXY_CIDRS and client_host not in ("127.0.0.1", "::1"):
|
||||
return client_host
|
||||
return forwarded.split(",")[0].strip() or client_host
|
||||
|
||||
|
||||
async def _resolve_partner_snapshot(session: AsyncSession, billing_user_id: int) -> dict[str, object]:
|
||||
partner_feature_enabled = False
|
||||
default_percent = 0.0
|
||||
try:
|
||||
from modules.partner_program import settings as partner_settings
|
||||
|
||||
partner_feature_enabled = True
|
||||
raw_percent = getattr(partner_settings, "PARTNER_BONUS_PERCENTAGES", {}).get(1, 0.0)
|
||||
default_percent = float(raw_percent) * 100.0
|
||||
except Exception:
|
||||
partner_feature_enabled = False
|
||||
default_percent = 0.0
|
||||
payload: dict[str, object] = {
|
||||
"partner_enabled": partner_feature_enabled,
|
||||
"partner_code": "",
|
||||
"partner_balance": 0.0,
|
||||
"partner_percent": default_percent,
|
||||
"partner_percent_custom": False,
|
||||
"partner_referred_total": 0,
|
||||
"partner_payout_method": None,
|
||||
}
|
||||
try:
|
||||
partner_row = (
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT
|
||||
tg_id,
|
||||
COALESCE(partner_balance, 0),
|
||||
partner_percent,
|
||||
COALESCE(partner_percent_custom, false),
|
||||
partner_code,
|
||||
payout_method
|
||||
FROM users
|
||||
WHERE id = :user_id
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"user_id": int(billing_user_id)},
|
||||
)
|
||||
).first()
|
||||
except Exception:
|
||||
partner_row = None
|
||||
if partner_row is None:
|
||||
return payload
|
||||
tg_id = int(partner_row[0]) if partner_row[0] is not None else None
|
||||
balance = float(partner_row[1] or 0.0)
|
||||
percent_raw = partner_row[2]
|
||||
percent_custom = bool(partner_row[3])
|
||||
percent_value = float(percent_raw) if (percent_custom and percent_raw is not None) else float(default_percent)
|
||||
code = str(partner_row[4] or "").strip()
|
||||
if (not code or code.isdigit() or code.startswith("r1_")) and int(billing_user_id) > 0:
|
||||
generated_code = encode_partner_code(int(billing_user_id))
|
||||
code = generated_code
|
||||
try:
|
||||
await session.execute(
|
||||
text("UPDATE users SET partner_code = :code WHERE id = :id"),
|
||||
{"code": generated_code, "id": int(billing_user_id)},
|
||||
)
|
||||
await session.flush()
|
||||
except Exception as e:
|
||||
logger.warning("[Auth] Ошибка сохранения partner_code для billing_user_id={}: {}", billing_user_id, e)
|
||||
payout_method = str(partner_row[5] or "").strip() or None
|
||||
referred_total = 0
|
||||
if tg_id is not None:
|
||||
try:
|
||||
referred_total = int(
|
||||
(
|
||||
await session.execute(
|
||||
text("SELECT COUNT(*) FROM partners WHERE partner_tg_id = :tg_id"),
|
||||
{"tg_id": int(tg_id)},
|
||||
)
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
except Exception:
|
||||
referred_total = 0
|
||||
payload.update({
|
||||
"partner_enabled": bool(partner_feature_enabled or code or referred_total > 0 or balance > 0),
|
||||
"partner_code": code,
|
||||
"partner_balance": balance,
|
||||
"partner_percent": percent_value,
|
||||
"partner_percent_custom": percent_custom,
|
||||
"partner_referred_total": referred_total,
|
||||
"partner_payout_method": payout_method,
|
||||
})
|
||||
return payload
|
||||
@@ -0,0 +1,43 @@
|
||||
"""In-memory rate limit fallback для случаев когда Redis недоступен.
|
||||
|
||||
Используется только когда Redis не ответил — чтобы критичные auth-эндпоинты
|
||||
не теряли защиту при кратковременных Redis-сбоях. Per-process, не шарится
|
||||
между репликами — поэтому на нескольких инстансах лимит будет N*limit.
|
||||
"""
|
||||
|
||||
import time
|
||||
from collections import deque
|
||||
from threading import Lock
|
||||
|
||||
|
||||
_BUCKETS: dict[str, deque[float]] = {}
|
||||
_LOCK = Lock()
|
||||
_MAX_KEYS = 10000
|
||||
|
||||
|
||||
def _prune(bucket: deque[float], window_sec: int) -> None:
|
||||
threshold = time.monotonic() - window_sec
|
||||
while bucket and bucket[0] < threshold:
|
||||
bucket.popleft()
|
||||
|
||||
|
||||
def _evict_if_full() -> None:
|
||||
if len(_BUCKETS) < _MAX_KEYS:
|
||||
return
|
||||
now = time.monotonic()
|
||||
dead = [k for k, b in _BUCKETS.items() if not b or b[-1] < now - 3600]
|
||||
for k in dead:
|
||||
del _BUCKETS[k]
|
||||
if len(_BUCKETS) >= _MAX_KEYS:
|
||||
oldest = min(_BUCKETS.keys(), key=lambda k: _BUCKETS[k][0] if _BUCKETS[k] else 0)
|
||||
del _BUCKETS[oldest]
|
||||
|
||||
|
||||
def check_and_increment(key: str, limit: int, window_sec: int) -> int:
|
||||
"""Возвращает текущее значение счётчика после инкремента. Если >= limit — превышение."""
|
||||
with _LOCK:
|
||||
_evict_if_full()
|
||||
bucket = _BUCKETS.setdefault(key, deque())
|
||||
_prune(bucket, window_sec)
|
||||
bucket.append(time.monotonic())
|
||||
return len(bucket)
|
||||
@@ -0,0 +1,79 @@
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
Field as PydanticField,
|
||||
)
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_identity_token
|
||||
from api.v2.routes.auth._common import _client_ip
|
||||
from mail import send_email_verify_code_email, smtp_configured
|
||||
from utils import web_email_verify_code as verify_util
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class VerifyEmailRequest(BaseModel):
|
||||
code: str = PydanticField(..., min_length=1, max_length=10)
|
||||
|
||||
|
||||
@router.post("/send-verify-code")
|
||||
async def send_email_verify_code(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
"""Отправить код подтверждения email. Требует авторизации."""
|
||||
if not smtp_configured():
|
||||
raise HTTPException(status_code=503, detail="Почтовый сервер не настроен")
|
||||
email = (identity.email or "").strip().lower()
|
||||
if not email:
|
||||
raise HTTPException(status_code=400, detail="Email не привязан к аккаунту")
|
||||
if getattr(identity, "email_verified", False):
|
||||
return {"ok": True, "detail": "Email уже подтверждён"}
|
||||
if not await verify_util.redis_ready():
|
||||
raise HTTPException(status_code=503, detail="Сервис временно недоступен")
|
||||
ip = _client_ip(request)
|
||||
if not await verify_util.try_consume_ip_send_budget(ip):
|
||||
raise HTTPException(status_code=429, detail="Слишком много запросов, попробуйте позже")
|
||||
if not await verify_util.try_consume_email_send_budget(email):
|
||||
raise HTTPException(status_code=429, detail="Слишком много запросов на этот email")
|
||||
if not await verify_util.try_acquire_resend_cooldown(email):
|
||||
raise HTTPException(status_code=429, detail="Подождите минуту перед повторной отправкой")
|
||||
code = f"{secrets.randbelow(900000) + 100000}"
|
||||
await verify_util.store_code(email, code)
|
||||
try:
|
||||
await send_email_verify_code_email(email, code)
|
||||
except Exception:
|
||||
await verify_util.delete_code(email)
|
||||
raise HTTPException(status_code=503, detail="Не удалось отправить письмо")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/verify-email")
|
||||
async def verify_email(
|
||||
body: VerifyEmailRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
"""Подтвердить email по коду."""
|
||||
email = (identity.email or "").strip().lower()
|
||||
if not email:
|
||||
raise HTTPException(status_code=400, detail="Email не привязан к аккаунту")
|
||||
if getattr(identity, "email_verified", False):
|
||||
return {"ok": True, "detail": "Email уже подтверждён"}
|
||||
if not await verify_util.try_consume_verify_budget(email):
|
||||
raise HTTPException(status_code=429, detail="Слишком много попыток, попробуйте позже")
|
||||
if not await verify_util.verify_and_consume_code(email, body.code.strip()):
|
||||
raise HTTPException(status_code=400, detail="Неверный или просроченный код")
|
||||
from sqlalchemy import update
|
||||
|
||||
from database.models import Identity as IdentityModel
|
||||
await session.execute(
|
||||
update(IdentityModel).where(IdentityModel.id == identity.id).values(email_verified=True)
|
||||
)
|
||||
return {"ok": True}
|
||||
@@ -0,0 +1,114 @@
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import (
|
||||
bind_identity_actor,
|
||||
get_session,
|
||||
verify_identity_token,
|
||||
)
|
||||
from api.v2.routes.auth._common import _client_ip
|
||||
from api.v2.schemas.identities import (
|
||||
IdentityResponse,
|
||||
LinkEmailConfirmRequest,
|
||||
LinkEmailSendCodeRequest,
|
||||
)
|
||||
from database import identities as idb
|
||||
from mail import send_email_link_code_email, smtp_configured
|
||||
from utils import web_email_link_code as email_link_code
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/link-email/send-code")
|
||||
async def link_email_send_code(
|
||||
body: LinkEmailSendCodeRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
email_norm = email_link_code.normalize_email(body.email)
|
||||
if not email_norm:
|
||||
raise HTTPException(status_code=400, detail="Укажите корректный email")
|
||||
if identity.email and str(identity.email).strip().lower() == email_norm:
|
||||
raise HTTPException(status_code=409, detail="Этот email уже привязан к аккаунту")
|
||||
if not smtp_configured():
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Отправка кода недоступна: почта не настроена на сервере",
|
||||
)
|
||||
if not await email_link_code.redis_ready():
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Сервис временно недоступен. Попробуйте позже.",
|
||||
)
|
||||
existing = await idb.get_identity_by_email(session, email_norm)
|
||||
if existing and existing.id != identity.id:
|
||||
raise HTTPException(status_code=400, detail="Не удалось привязать email")
|
||||
ip = _client_ip(request)
|
||||
if not await email_link_code.try_consume_ip_budget(ip):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Слишком много запросов с вашего адреса. Попробуйте позже.",
|
||||
)
|
||||
if not await email_link_code.try_consume_email_send_budget(email_norm):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Слишком много запросов для этого адреса. Попробуйте позже.",
|
||||
)
|
||||
if not await email_link_code.try_acquire_cooldown(email_norm):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Код уже отправлен. Подождите перед повторной отправкой.",
|
||||
)
|
||||
code = "".join(secrets.choice("0123456789") for _ in range(6))
|
||||
if not await email_link_code.store_code(email_norm, code):
|
||||
await email_link_code.release_cooldown(email_norm)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Не удалось сохранить код. Попробуйте позже.",
|
||||
)
|
||||
try:
|
||||
await send_email_link_code_email(email_norm, code)
|
||||
except Exception:
|
||||
await email_link_code.release_cooldown(email_norm)
|
||||
await email_link_code.delete_code(email_norm)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Не удалось отправить письмо. Попробуйте позже.",
|
||||
) from None
|
||||
return {"ok": True, "message": "Код подтверждения отправлен на почту"}
|
||||
|
||||
|
||||
@router.post("/link-email/confirm", response_model=IdentityResponse)
|
||||
async def link_email_confirm(
|
||||
body: LinkEmailConfirmRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
email_norm = email_link_code.normalize_email(body.email)
|
||||
if not email_norm or not body.code or not str(body.code).strip():
|
||||
raise HTTPException(status_code=400, detail="Email и код обязательны")
|
||||
if not await email_link_code.redis_ready():
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Сервис временно недоступен. Попробуйте позже.",
|
||||
)
|
||||
if not await email_link_code.try_consume_email_verify_budget(email_norm):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Слишком много попыток. Запросите новый код.",
|
||||
)
|
||||
if not await email_link_code.verify_and_consume_code(email_norm, str(body.code).strip()):
|
||||
raise HTTPException(status_code=401, detail="Неверный код или срок действия истёк")
|
||||
result = await idb.attach_email(session, identity.id, email_norm)
|
||||
if not result:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Этот email уже привязан к другой идентичности",
|
||||
)
|
||||
await bind_identity_actor(request, session, result)
|
||||
return IdentityResponse.model_validate(result)
|
||||
@@ -0,0 +1,389 @@
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import (
|
||||
bind_identity_actor,
|
||||
get_session,
|
||||
set_auth_cookie,
|
||||
set_is_admin_cookie,
|
||||
)
|
||||
from api.v2.routes.auth._common import TOKEN_TTL_HINT, _client_ip
|
||||
from api.v2.schemas.identities import (
|
||||
ConfirmPasswordResetRequest,
|
||||
LoginByCodeRequest,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
RegisterByEmailRequest,
|
||||
RegisterResponse,
|
||||
SendLoginCodeRequest,
|
||||
)
|
||||
from database import (
|
||||
add_referral,
|
||||
get_referral_by_referred_id,
|
||||
identities as idb,
|
||||
)
|
||||
from database.access.resolution import resolve_user_optional
|
||||
from logger import logger
|
||||
from mail import (
|
||||
send_email_verify_code_email,
|
||||
send_login_code_email,
|
||||
send_password_reset_code_email,
|
||||
smtp_configured,
|
||||
)
|
||||
from utils import (
|
||||
web_email_verify_code as email_verify,
|
||||
web_password_reset_code as pwd_reset,
|
||||
)
|
||||
from utils.disposable_emails import is_disposable_email
|
||||
from utils.referral_codes import decode_referral_code
|
||||
from utils.turnstile import turnstile_enabled, verify_turnstile_token
|
||||
from utils.web_login_code import (
|
||||
delete_code,
|
||||
normalize_login_email,
|
||||
redis_ready_for_login_codes,
|
||||
release_resend_cooldown,
|
||||
store_code,
|
||||
try_acquire_resend_cooldown,
|
||||
try_consume_email_send_budget,
|
||||
try_consume_email_verify_budget,
|
||||
try_consume_ip_send_budget,
|
||||
verify_and_consume_code,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_RESET_OK_MESSAGE = {
|
||||
"ok": True,
|
||||
"message": "Если для этого адреса есть аккаунт с паролем, мы отправили код. Проверьте почту.",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/register", response_model=RegisterResponse)
|
||||
async def register_by_email(
|
||||
body: RegisterByEmailRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
(
|
||||
"""Регистрация по почте и паролю: создаётся идентичность, выдаётся токен. Срок действия токена: """
|
||||
+ TOKEN_TTL_HINT
|
||||
+ "."
|
||||
)
|
||||
ip = _client_ip(request)
|
||||
try:
|
||||
from core.redis_cache import cache_incr_checked
|
||||
from api.v2.routes.auth._fallback_limiter import check_and_increment
|
||||
count, redis_ok = await cache_incr_checked(f"register_rate:{ip}", 3600)
|
||||
if not redis_ok:
|
||||
count = check_and_increment(f"register_rate:{ip}", 5, 3600)
|
||||
if count > 5:
|
||||
raise HTTPException(status_code=429, detail="Слишком много регистраций с этого IP. Попробуйте позже.")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
pass
|
||||
if turnstile_enabled():
|
||||
if not await verify_turnstile_token(body.turnstile_token, ip):
|
||||
raise HTTPException(status_code=400, detail="Проверка CAPTCHA не пройдена")
|
||||
email = body.email.strip().lower()
|
||||
if not email:
|
||||
raise HTTPException(status_code=400, detail="Email обязателен")
|
||||
if is_disposable_email(email):
|
||||
raise HTTPException(status_code=400, detail="Одноразовые email-адреса не поддерживаются")
|
||||
if not body.password or len(body.password) < 8:
|
||||
raise HTTPException(status_code=400, detail="Пароль минимум 8 символов")
|
||||
existing = await idb.get_identity_by_email(session, email)
|
||||
if existing:
|
||||
raise HTTPException(status_code=409, detail="Идентичность с таким email уже существует")
|
||||
raw_referral = str(body.referral_code or "").strip()
|
||||
if "/referral/" in raw_referral:
|
||||
raw_referral = raw_referral.split("/referral/", 1)[-1]
|
||||
if "start=referral_" in raw_referral:
|
||||
raw_referral = raw_referral.split("start=referral_", 1)[-1]
|
||||
raw_referral = raw_referral.split("?", 1)[0].split("#", 1)[0].strip()
|
||||
referrer_legacy = decode_referral_code(raw_referral)
|
||||
referrer_user = None
|
||||
if body.referral_code and referrer_legacy is None:
|
||||
raise HTTPException(status_code=400, detail="Код приглашения недействителен")
|
||||
if referrer_legacy is not None:
|
||||
referrer_user = await resolve_user_optional(session, referrer_legacy)
|
||||
if referrer_user is None:
|
||||
raise HTTPException(status_code=400, detail="Код приглашения недействителен")
|
||||
identity, token = await idb.create_identity_with_token(session, email=email, password=body.password)
|
||||
await bind_identity_actor(request, session, identity)
|
||||
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
if referrer_user is not None and not await get_referral_by_referred_id(session, billing_user_id):
|
||||
await add_referral(session, billing_user_id, referrer_user.id)
|
||||
if smtp_configured():
|
||||
try:
|
||||
code = f"{secrets.randbelow(900000) + 100000}"
|
||||
await email_verify.store_code(email, code)
|
||||
await send_email_verify_code_email(email, code)
|
||||
except Exception as e:
|
||||
logger.warning("[Auth] Не удалось отправить код подтверждения email при регистрации: {}", e)
|
||||
logger.info("[Auth] Register success: identity={}, email={}, ip={}", identity.id, email, _client_ip(request))
|
||||
set_auth_cookie(response, token, request)
|
||||
set_is_admin_cookie(response, identity, request)
|
||||
return RegisterResponse(identity_id=identity.id)
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
async def login(
|
||||
body: LoginRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Вход по email и паролю. Возвращает identity_id и новый токен. Срок действия токена: """ + TOKEN_TTL_HINT + "."
|
||||
email = body.email.strip().lower()
|
||||
if not email:
|
||||
raise HTTPException(status_code=400, detail="Email обязателен")
|
||||
ip = _client_ip(request)
|
||||
try:
|
||||
from core.redis_cache import cache_get, cache_incr_checked
|
||||
from api.v2.routes.auth._fallback_limiter import check_and_increment
|
||||
lockout_key = f"login_lockout:{email}"
|
||||
locked = await cache_get(lockout_key)
|
||||
if locked:
|
||||
raise HTTPException(status_code=429, detail="Аккаунт временно заблокирован. Попробуйте через 15 минут.")
|
||||
rkey = f"login_pwd_rate:{ip}"
|
||||
count, redis_ok = await cache_incr_checked(rkey, 900)
|
||||
if not redis_ok:
|
||||
count = check_and_increment(rkey, 10, 900)
|
||||
if count > 10:
|
||||
raise HTTPException(status_code=429, detail="Слишком много попыток. Попробуйте позже.")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning("[Auth] Ошибка rate-limit проверки для email-логина: {}", e)
|
||||
result = await idb.login_by_email(session, email, body.password)
|
||||
if not result:
|
||||
try:
|
||||
from core.redis_cache import cache_incr, cache_set
|
||||
fail_key = f"login_fail:{email}"
|
||||
fails = await cache_incr(fail_key, 900)
|
||||
if fails >= 10:
|
||||
await cache_set(f"login_lockout:{email}", "1", 900)
|
||||
except Exception:
|
||||
pass
|
||||
raise HTTPException(status_code=401, detail="Неверный email или пароль")
|
||||
try:
|
||||
from core.redis_cache import cache_delete
|
||||
await cache_delete(f"login_fail:{email}")
|
||||
except Exception:
|
||||
pass
|
||||
identity, token = result
|
||||
await bind_identity_actor(request, session, identity)
|
||||
logger.info("[Auth] Login success: identity={}, email={}, ip={}, method=password", identity.id, email, ip)
|
||||
set_auth_cookie(response, token, request)
|
||||
set_is_admin_cookie(response, identity, request)
|
||||
return LoginResponse(identity_id=identity.id)
|
||||
|
||||
|
||||
@router.post("/send-login-code")
|
||||
async def send_login_code(
|
||||
body: SendLoginCodeRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Отправить код входа на email (SMTP + Redis)."""
|
||||
ip = _client_ip(request)
|
||||
try:
|
||||
from core.redis_cache import cache_incr_checked
|
||||
from api.v2.routes.auth._fallback_limiter import check_and_increment
|
||||
count, redis_ok = await cache_incr_checked(f"send_code_rate:{ip}", 3600)
|
||||
if not redis_ok:
|
||||
count = check_and_increment(f"send_code_rate:{ip}", 10, 3600)
|
||||
if count > 10:
|
||||
raise HTTPException(status_code=429, detail="Слишком много запросов кодов. Попробуйте позже.")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
pass
|
||||
if turnstile_enabled():
|
||||
if not await verify_turnstile_token(body.turnstile_token, ip):
|
||||
raise HTTPException(status_code=400, detail="Проверка CAPTCHA не пройдена")
|
||||
email_norm = normalize_login_email(body.email)
|
||||
if not email_norm:
|
||||
raise HTTPException(status_code=400, detail="Email обязателен")
|
||||
if is_disposable_email(email_norm):
|
||||
raise HTTPException(status_code=400, detail="Одноразовые email-адреса не поддерживаются")
|
||||
if not smtp_configured():
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Отправка кода недоступна: почта не настроена на сервере",
|
||||
)
|
||||
if not await redis_ready_for_login_codes():
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Сервис временно недоступен. Попробуйте позже.",
|
||||
)
|
||||
identity = await idb.get_identity_by_email(session, email_norm)
|
||||
if not identity:
|
||||
if not body.allow_register:
|
||||
return {"ok": True, "message": "Код отправлен на почту"}
|
||||
identity = await idb.create_identity(session, email=email_norm)
|
||||
ip = _client_ip(request)
|
||||
if not await try_consume_ip_send_budget(ip):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Слишком много запросов с вашего адреса. Попробуйте позже.",
|
||||
)
|
||||
if not await try_consume_email_send_budget(email_norm):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Слишком много запросов для этого адреса. Попробуйте позже.",
|
||||
)
|
||||
if not await try_acquire_resend_cooldown(email_norm):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Код уже отправлен. Подождите перед повторной отправкой.",
|
||||
)
|
||||
code = "".join(secrets.choice("0123456789") for _ in range(6))
|
||||
if not await store_code(email_norm, code):
|
||||
await release_resend_cooldown(email_norm)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Не удалось сохранить код. Попробуйте позже.",
|
||||
)
|
||||
try:
|
||||
await send_login_code_email(email_norm, code)
|
||||
except Exception:
|
||||
await release_resend_cooldown(email_norm)
|
||||
await delete_code(email_norm)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Не удалось отправить письмо. Попробуйте позже.",
|
||||
) from None
|
||||
return {"ok": True, "message": "Код отправлен на почту"}
|
||||
|
||||
|
||||
@router.post("/login-by-code", response_model=LoginResponse)
|
||||
async def login_by_code(
|
||||
body: LoginByCodeRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Вход по email и коду из письма."""
|
||||
email_norm = normalize_login_email(body.email)
|
||||
if not email_norm or not body.code or not body.code.strip():
|
||||
raise HTTPException(status_code=400, detail="Email и код обязательны")
|
||||
if not await redis_ready_for_login_codes():
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Сервис временно недоступен. Попробуйте позже.",
|
||||
)
|
||||
if not await try_consume_email_verify_budget(email_norm):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Слишком много попыток. Запросите новый код.",
|
||||
)
|
||||
if not await verify_and_consume_code(email_norm, body.code.strip()):
|
||||
raise HTTPException(status_code=401, detail="Неверный код или срок действия истёк")
|
||||
identity = await idb.get_identity_by_email(session, email_norm)
|
||||
if not identity:
|
||||
raise HTTPException(status_code=401, detail="Аккаунт не найден")
|
||||
if not getattr(identity, "email_verified", False):
|
||||
from sqlalchemy import update as sa_update
|
||||
|
||||
from database.models import Identity as IdentityModel
|
||||
await session.execute(sa_update(IdentityModel).where(IdentityModel.id == identity.id).values(email_verified=True))
|
||||
await bind_identity_actor(request, session, identity)
|
||||
token = await idb.issue_token_for_identity(session, identity)
|
||||
logger.info("[Auth] Login success: identity={}, email={}, method=code", identity.id, email_norm)
|
||||
set_auth_cookie(response, token, request)
|
||||
set_is_admin_cookie(response, identity, request)
|
||||
return LoginResponse(identity_id=identity.id)
|
||||
|
||||
|
||||
@router.post("/request-password-reset")
|
||||
async def request_password_reset(
|
||||
body: SendLoginCodeRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
email_norm = normalize_login_email(body.email)
|
||||
if not email_norm:
|
||||
raise HTTPException(status_code=400, detail="Email обязателен")
|
||||
if not smtp_configured() or not await pwd_reset.redis_ready():
|
||||
return _RESET_OK_MESSAGE
|
||||
identity = await idb.get_identity_by_email(session, email_norm)
|
||||
if not identity or not identity.password_hash:
|
||||
return _RESET_OK_MESSAGE
|
||||
ip = _client_ip(request)
|
||||
if not await pwd_reset.try_consume_ip_budget(ip):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Слишком много запросов с вашего адреса. Попробуйте позже.",
|
||||
)
|
||||
if not await pwd_reset.try_consume_email_send_budget(email_norm):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Слишком много запросов для этого адреса. Попробуйте позже.",
|
||||
)
|
||||
if not await pwd_reset.try_acquire_cooldown(email_norm):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Код уже отправлен. Подождите перед повторной отправкой.",
|
||||
)
|
||||
code = "".join(secrets.choice("0123456789") for _ in range(6))
|
||||
if not await pwd_reset.store_code(email_norm, code):
|
||||
await pwd_reset.release_cooldown(email_norm)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Не удалось сохранить код. Попробуйте позже.",
|
||||
)
|
||||
try:
|
||||
await send_password_reset_code_email(email_norm, code)
|
||||
except Exception:
|
||||
await pwd_reset.release_cooldown(email_norm)
|
||||
await pwd_reset.delete_code(email_norm)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Не удалось отправить письмо. Попробуйте позже.",
|
||||
) from None
|
||||
return _RESET_OK_MESSAGE
|
||||
|
||||
|
||||
@router.post("/confirm-password-reset", response_model=LoginResponse)
|
||||
async def confirm_password_reset(
|
||||
body: ConfirmPasswordResetRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
email_norm = normalize_login_email(body.email)
|
||||
if not email_norm or not body.code or not body.code.strip():
|
||||
raise HTTPException(status_code=400, detail="Email и код обязательны")
|
||||
if body.password != body.password_confirm:
|
||||
raise HTTPException(status_code=400, detail="Пароли не совпадают")
|
||||
if len(body.password) < 8:
|
||||
raise HTTPException(status_code=400, detail="Пароль минимум 8 символов")
|
||||
if not await pwd_reset.redis_ready():
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Сервис временно недоступен. Попробуйте позже.",
|
||||
)
|
||||
if not await pwd_reset.try_consume_email_verify_budget(email_norm):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Слишком много попыток. Запросите новый код.",
|
||||
)
|
||||
if not await pwd_reset.verify_and_consume_code(email_norm, body.code.strip()):
|
||||
raise HTTPException(status_code=401, detail="Неверный код или срок действия истёк")
|
||||
identity = await idb.get_identity_by_email(session, email_norm)
|
||||
if not identity:
|
||||
raise HTTPException(status_code=400, detail="Аккаунт не найден")
|
||||
updated = await idb.set_password_for_identity(session, identity.id, body.password)
|
||||
if not updated:
|
||||
raise HTTPException(status_code=400, detail="Не удалось обновить пароль")
|
||||
await bind_identity_actor(request, session, updated)
|
||||
token = await idb.issue_token_for_identity(session, updated)
|
||||
set_auth_cookie(response, token, request)
|
||||
set_is_admin_cookie(response, updated, request)
|
||||
return LoginResponse(identity_id=updated.id)
|
||||
@@ -0,0 +1,151 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import (
|
||||
bind_identity_actor,
|
||||
clear_auth_cookie,
|
||||
get_request_actor,
|
||||
get_session,
|
||||
verify_identity_token,
|
||||
)
|
||||
from api.v2.routes.auth._common import _resolve_partner_snapshot
|
||||
from api.v2.schemas.identities import (
|
||||
ChangePasswordRequest,
|
||||
IdentityResponse,
|
||||
SetPasswordRequest,
|
||||
)
|
||||
from api.v2.schemas.web_public import AccountSummaryResponse
|
||||
from database import (
|
||||
get_balance,
|
||||
get_keys,
|
||||
get_trial,
|
||||
identities as idb,
|
||||
)
|
||||
from database.models import CouponUsage, Gift, GiftUsage
|
||||
from database.referrals import get_referral_stats
|
||||
from database.web_notifications import count_unread_for_identity
|
||||
from utils.referral_codes import encode_referral_code
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/me", response_model=IdentityResponse)
|
||||
async def me(
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
"""Текущая идентичность по HttpOnly cookie `auth_token`."""
|
||||
return IdentityResponse.model_validate(identity)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(
|
||||
request: Request,
|
||||
response: Response,
|
||||
):
|
||||
"""Очищает auth cookie. Не требует валидной сессии — всегда возвращает ok."""
|
||||
clear_auth_cookie(response, request)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/summary", response_model=AccountSummaryResponse)
|
||||
async def auth_summary(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
actor = get_request_actor(request)
|
||||
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
|
||||
if billing_user_id is None:
|
||||
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
balance = float(await get_balance(session, billing_user_id))
|
||||
trial_status = await get_trial(session, billing_user_id)
|
||||
keys = await get_keys(session, billing_user_id)
|
||||
keys_total = len(keys) if keys else 0
|
||||
gifts_sent_r = await session.execute(
|
||||
select(func.count()).select_from(Gift).where(Gift.sender_user_id == billing_user_id)
|
||||
)
|
||||
gifts_sent = gifts_sent_r.scalar_one() or 0
|
||||
gifts_claimed_r = await session.execute(
|
||||
select(func.count()).select_from(GiftUsage).where(GiftUsage.user_id == billing_user_id)
|
||||
)
|
||||
gifts_claimed = gifts_claimed_r.scalar_one() or 0
|
||||
coupons_r = await session.execute(
|
||||
select(func.count()).select_from(CouponUsage).where(CouponUsage.user_id == billing_user_id)
|
||||
)
|
||||
coupons_used = coupons_r.scalar_one() or 0
|
||||
ref = await get_referral_stats(session, billing_user_id)
|
||||
partner = await _resolve_partner_snapshot(session, int(billing_user_id))
|
||||
unread_notifications = await count_unread_for_identity(session, identity.id)
|
||||
return AccountSummaryResponse(
|
||||
identity_id=identity.id,
|
||||
email=identity.email,
|
||||
tg_id=identity.tg_id,
|
||||
linked_telegram=identity.tg_id is not None,
|
||||
referral_code=encode_referral_code(int(billing_user_id)),
|
||||
balance=balance,
|
||||
trial_status=int(trial_status),
|
||||
keys_total=keys_total,
|
||||
referrals_total=int(ref.get("total_referrals") or 0),
|
||||
referrals_active=int(ref.get("active_referrals") or 0),
|
||||
referral_bonus_total=float(ref.get("total_referral_bonus") or 0),
|
||||
gifts_sent=int(gifts_sent),
|
||||
gifts_claimed=int(gifts_claimed),
|
||||
coupons_used=int(coupons_used),
|
||||
partner_enabled=bool(partner.get("partner_enabled", False)),
|
||||
partner_code=str(partner.get("partner_code") or ""),
|
||||
partner_balance=float(partner.get("partner_balance") or 0.0),
|
||||
partner_percent=float(partner.get("partner_percent") or 0.0),
|
||||
partner_percent_custom=bool(partner.get("partner_percent_custom", False)),
|
||||
partner_referred_total=int(partner.get("partner_referred_total") or 0),
|
||||
partner_payout_method=partner.get("partner_payout_method"),
|
||||
unread_notifications=int(unread_notifications),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/set-password")
|
||||
async def set_password(
|
||||
body: SetPasswordRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
if body.password != body.password_confirm:
|
||||
raise HTTPException(status_code=400, detail="Пароли не совпадают")
|
||||
updated = await idb.set_initial_password(session, identity.id, body.password)
|
||||
if not updated:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Пароль уже установлен или аккаунт недоступен",
|
||||
)
|
||||
await bind_identity_actor(request, session, updated)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/change-password")
|
||||
async def change_password(
|
||||
body: ChangePasswordRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
if body.password != body.password_confirm:
|
||||
raise HTTPException(status_code=400, detail="Новые пароли не совпадают")
|
||||
err = await idb.change_identity_password(
|
||||
session,
|
||||
identity.id,
|
||||
body.current_password,
|
||||
body.password,
|
||||
)
|
||||
if err == "no_password":
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Пароль ещё не установлен. Сначала задайте пароль в кабинете.",
|
||||
)
|
||||
if err == "wrong_password":
|
||||
raise HTTPException(status_code=401, detail="Неверный текущий пароль")
|
||||
refreshed = await idb.get_identity_by_id(session, identity.id)
|
||||
if refreshed:
|
||||
await bind_identity_actor(request, session, refreshed)
|
||||
return {"ok": True}
|
||||
@@ -0,0 +1,101 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
Field as PydanticField,
|
||||
)
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import (
|
||||
bind_identity_actor,
|
||||
get_session,
|
||||
set_auth_cookie,
|
||||
set_is_admin_cookie,
|
||||
verify_identity_token,
|
||||
)
|
||||
from api.v2.routes.auth._common import TELEGRAM_LOGIN_MAX_AGE, TOKEN_TTL_HINT, _client_ip
|
||||
from api.v2.schemas.identities import (
|
||||
IdentityResponse,
|
||||
LinkTelegramRequest,
|
||||
LoginResponse,
|
||||
LoginTelegramRequest,
|
||||
)
|
||||
from config import API_TOKEN
|
||||
from database import identities as idb
|
||||
from logger import logger
|
||||
from utils.telegram_login import verify_telegram_login
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class LoginTelegramWebAppRequest(BaseModel):
|
||||
init_data: str = PydanticField(..., min_length=1)
|
||||
|
||||
|
||||
@router.post("/login-telegram", response_model=LoginResponse)
|
||||
async def login_telegram(
|
||||
body: LoginTelegramRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
(
|
||||
"""Вход через Telegram Login Widget (кнопка на сайте). По tg_id находим или создаём Identity, выдаём токен. Срок действия токена: """
|
||||
+ TOKEN_TTL_HINT
|
||||
+ "."
|
||||
)
|
||||
payload = body.model_dump(mode="json")
|
||||
if not verify_telegram_login(payload, API_TOKEN, max_age_seconds=TELEGRAM_LOGIN_MAX_AGE):
|
||||
raise HTTPException(status_code=401, detail="Неверная подпись или устаревшие данные от Telegram")
|
||||
identity = await idb.get_or_create_identity_for_tg(session, body.id)
|
||||
await bind_identity_actor(request, session, identity)
|
||||
token = await idb.issue_token_for_identity(session, identity)
|
||||
logger.info("[Auth] Login success: identity={}, tg_id={}, ip={}, method=telegram_widget", identity.id, body.id, _client_ip(request))
|
||||
set_auth_cookie(response, token, request)
|
||||
set_is_admin_cookie(response, identity, request)
|
||||
return LoginResponse(identity_id=identity.id)
|
||||
|
||||
|
||||
@router.post("/login-telegram-webapp", response_model=LoginResponse)
|
||||
async def login_telegram_webapp(
|
||||
body: LoginTelegramWebAppRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Вход через Telegram WebApp initData. Валидирует HMAC, находит/создаёт Identity по tg_id."""
|
||||
from utils.telegram_login import verify_webapp_init_data
|
||||
result = verify_webapp_init_data(body.init_data, API_TOKEN)
|
||||
if not result:
|
||||
raise HTTPException(status_code=401, detail="Неверная подпись initData")
|
||||
tg_id = result.get("user_id")
|
||||
if not tg_id:
|
||||
raise HTTPException(status_code=401, detail="Не удалось определить пользователя из initData")
|
||||
identity = await idb.get_or_create_identity_for_tg(session, int(tg_id))
|
||||
await bind_identity_actor(request, session, identity)
|
||||
token = await idb.issue_token_for_identity(session, identity)
|
||||
logger.info("[Auth] Login success: identity={}, tg_id={}, ip={}, method=telegram_webapp", identity.id, tg_id, _client_ip(request))
|
||||
set_auth_cookie(response, token, request)
|
||||
set_is_admin_cookie(response, identity, request)
|
||||
return LoginResponse(identity_id=identity.id)
|
||||
|
||||
|
||||
@router.post("/link-telegram", response_model=IdentityResponse)
|
||||
async def link_telegram(
|
||||
body: LinkTelegramRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
"""Привязывает Telegram к текущей идентичности. Требуется подпись от Telegram Login Widget (доказательство владения аккаунтом)."""
|
||||
payload = body.model_dump(mode="json")
|
||||
if not verify_telegram_login(payload, API_TOKEN, max_age_seconds=TELEGRAM_LOGIN_MAX_AGE):
|
||||
raise HTTPException(status_code=401, detail="Неверная подпись или устаревшие данные от Telegram")
|
||||
result = await idb.attach_telegram(session, identity.id, body.id)
|
||||
if not result:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Этот Telegram уже привязан к другой идентичности",
|
||||
)
|
||||
await bind_identity_actor(request, session, result)
|
||||
return IdentityResponse.model_validate(result)
|
||||
@@ -0,0 +1,31 @@
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from services.coupons import resolve_percent_coupon
|
||||
from services.errors import ServiceError
|
||||
|
||||
|
||||
async def resolve_percent_coupon_pricing(
|
||||
session: AsyncSession,
|
||||
billing_user_id: int,
|
||||
base_price_rub: int,
|
||||
coupon_code: str | None,
|
||||
) -> tuple[int, int, int | None, str | None]:
|
||||
"""Применяет процентный купон. Бросает HTTPException при ошибке."""
|
||||
try:
|
||||
return await resolve_percent_coupon(
|
||||
session=session,
|
||||
billing_user_id=billing_user_id,
|
||||
base_price_rub=base_price_rub,
|
||||
coupon_code=coupon_code,
|
||||
)
|
||||
except ServiceError as e:
|
||||
status_map = {
|
||||
"not_found": 404,
|
||||
"limit_exceeded": 409,
|
||||
"validation_error": 400,
|
||||
}
|
||||
raise HTTPException(
|
||||
status_code=status_map.get(e.code, 400),
|
||||
detail=e.message,
|
||||
)
|
||||
@@ -1,8 +1,14 @@
|
||||
from fastapi import APIRouter
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
|
||||
from api.v2.base_crud import generate_crud_router
|
||||
from api.v2.schemas import CouponBase, CouponResponse, CouponUpdate
|
||||
from api.v2.schemas.web_public import CouponApplyRequest, CouponApplyResponse
|
||||
from api.depends import get_request_actor, get_session, verify_identity_token
|
||||
from database import identities as idb
|
||||
from database.models import Coupon
|
||||
from services.coupons import apply_fixed_coupon
|
||||
from services.errors import LimitExceededError, NotFoundError, ServiceError, ValidationError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
router = generate_crud_router(
|
||||
model=Coupon,
|
||||
@@ -13,3 +19,53 @@ router = generate_crud_router(
|
||||
parameter_name="code",
|
||||
enabled_methods=["get_all", "get_one", "create", "update", "delete"],
|
||||
)
|
||||
|
||||
|
||||
async def _resolve_coupon_user_id(session: AsyncSession, request: Request, identity) -> tuple[int, int | None]:
|
||||
actor = get_request_actor(request)
|
||||
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
|
||||
if billing_user_id is None:
|
||||
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
tg_id = actor.telegram_chat_id if actor else None
|
||||
return int(billing_user_id), tg_id
|
||||
|
||||
|
||||
def _service_error_to_http(e: ServiceError) -> HTTPException:
|
||||
status_map = {
|
||||
"not_found": 404,
|
||||
"limit_exceeded": 409,
|
||||
"validation_error": 400,
|
||||
"forbidden": 403,
|
||||
}
|
||||
return HTTPException(status_code=status_map.get(e.code, 400), detail=e.message)
|
||||
|
||||
|
||||
@router.post("/apply", response_model=CouponApplyResponse)
|
||||
async def apply_coupon(
|
||||
body: CouponApplyRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
user_id, tg_id = await _resolve_coupon_user_id(session, request, identity)
|
||||
try:
|
||||
result = await apply_fixed_coupon(
|
||||
session=session,
|
||||
user_id=user_id,
|
||||
tg_id=tg_id,
|
||||
code=str(body.code or ""),
|
||||
)
|
||||
await session.commit()
|
||||
return CouponApplyResponse(
|
||||
ok=True,
|
||||
message="Купон успешно активирован",
|
||||
coupon_code=result.coupon_code,
|
||||
amount=result.amount,
|
||||
balance=result.balance,
|
||||
)
|
||||
except ServiceError as e:
|
||||
await session.rollback()
|
||||
raise _service_error_to_http(e)
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise HTTPException(status_code=500, detail="Ошибка активации купона")
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, UTC
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_identity_admin
|
||||
from api.v2.schemas.flows import FlowCreate, FlowResponse, FlowUpdate
|
||||
from database.models import WebFlow
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _flow_to_response(flow: WebFlow) -> FlowResponse:
|
||||
return FlowResponse(
|
||||
id=flow.id,
|
||||
name=flow.name,
|
||||
nodes=flow.nodes or [],
|
||||
edges=flow.edges or [],
|
||||
entry_node_id=flow.entry_node_id,
|
||||
version=flow.version,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/flows/{flow_id}", response_model=FlowResponse)
|
||||
async def get_flow_public(flow_id: str, session: AsyncSession = Depends(get_session)):
|
||||
flow = await session.get(WebFlow, flow_id)
|
||||
if not flow:
|
||||
raise HTTPException(404, "Flow not found")
|
||||
return _flow_to_response(flow)
|
||||
|
||||
|
||||
@router.get("/admin/flows", response_model=list[FlowResponse])
|
||||
async def list_flows(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_identity=Depends(verify_identity_admin),
|
||||
):
|
||||
result = await session.execute(select(WebFlow))
|
||||
return [_flow_to_response(f) for f in result.scalars().all()]
|
||||
|
||||
|
||||
@router.get("/admin/flows/{flow_id}", response_model=FlowResponse)
|
||||
async def get_flow_admin(
|
||||
flow_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_identity=Depends(verify_identity_admin),
|
||||
):
|
||||
flow = await session.get(WebFlow, flow_id)
|
||||
if not flow:
|
||||
raise HTTPException(404, "Flow not found")
|
||||
return _flow_to_response(flow)
|
||||
|
||||
|
||||
@router.post("/admin/flows", response_model=FlowResponse, status_code=201)
|
||||
async def create_flow(
|
||||
body: FlowCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_identity=Depends(verify_identity_admin),
|
||||
):
|
||||
existing = await session.get(WebFlow, body.id)
|
||||
if existing:
|
||||
raise HTTPException(409, "Flow with this ID already exists")
|
||||
|
||||
flow = WebFlow(
|
||||
id=body.id,
|
||||
name=body.name,
|
||||
nodes=[n.model_dump() for n in body.nodes],
|
||||
edges=[e.model_dump() for e in body.edges],
|
||||
entry_node_id=body.entry_node_id,
|
||||
version=1,
|
||||
updated_at=datetime.now(UTC),
|
||||
)
|
||||
session.add(flow)
|
||||
await session.commit()
|
||||
await session.refresh(flow)
|
||||
return _flow_to_response(flow)
|
||||
|
||||
|
||||
@router.put("/admin/flows/{flow_id}", response_model=FlowResponse)
|
||||
async def update_flow(
|
||||
flow_id: str,
|
||||
body: FlowUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_identity=Depends(verify_identity_admin),
|
||||
):
|
||||
flow = await session.get(WebFlow, flow_id)
|
||||
if not flow:
|
||||
raise HTTPException(404, "Flow not found")
|
||||
|
||||
if body.name is not None:
|
||||
flow.name = body.name
|
||||
flow.nodes = [n.model_dump() for n in body.nodes]
|
||||
flow.edges = [e.model_dump() for e in body.edges]
|
||||
flow.entry_node_id = body.entry_node_id
|
||||
flow.version = flow.version + 1
|
||||
flow.updated_at = datetime.now(UTC)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(flow)
|
||||
return _flow_to_response(flow)
|
||||
|
||||
|
||||
@router.delete("/admin/flows/{flow_id}", status_code=204)
|
||||
async def delete_flow(
|
||||
flow_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_identity=Depends(verify_identity_admin),
|
||||
):
|
||||
flow = await session.get(WebFlow, flow_id)
|
||||
if not flow:
|
||||
raise HTTPException(404, "Flow not found")
|
||||
await session.delete(flow)
|
||||
await session.commit()
|
||||
+274
-5
@@ -1,14 +1,251 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path
|
||||
from sqlalchemy import delete, select
|
||||
from math import ceil
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_identity_admin
|
||||
from api.depends import (
|
||||
get_request_actor,
|
||||
get_session,
|
||||
validate_redirect_url,
|
||||
verify_identity_admin,
|
||||
verify_identity_token,
|
||||
)
|
||||
from api.v2.base_crud import generate_crud_router
|
||||
from api.v2.routes.tariffs import _resolve_default_web_payment_provider, _resolve_public_base_url
|
||||
from api.v2.schemas import GiftBase, GiftResponse, GiftUpdate, GiftUsageResponse
|
||||
from database.models import Gift, GiftUsage
|
||||
from api.v2.schemas.web_public import (
|
||||
GiftCreatePreviewResponse,
|
||||
GiftCreateRequest,
|
||||
GiftCreateResponse,
|
||||
GiftRedeemRequest,
|
||||
GiftRedeemResponse,
|
||||
GiftUsageEntry,
|
||||
MyGiftItem,
|
||||
MyGiftsResponse,
|
||||
)
|
||||
from config import GIFT_BUTTON
|
||||
from core.bootstrap import BUTTONS_CONFIG
|
||||
from database import (
|
||||
get_balance,
|
||||
identities as idb,
|
||||
)
|
||||
from database.access.resolution import resolve_user_optional
|
||||
from database.models import Gift, GiftUsage, Tariff
|
||||
from database.tariffs import get_tariff_by_id
|
||||
from database.temporary_data import create_temporary_data
|
||||
from services.errors import NotFoundError, ValidationError
|
||||
from services.formatting import get_site_gift_link
|
||||
from services.gifts import create_gift as service_create_gift
|
||||
from services.gifts import redeem_gift as service_redeem_gift
|
||||
from services.payments.payment_links import PaymentLinkRequest, create_payment_link
|
||||
from services.tariffs import calculate_config_price
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _check_gifts_enabled():
|
||||
if not bool(BUTTONS_CONFIG.get("GIFT_BUTTON_ENABLE", GIFT_BUTTON)):
|
||||
raise HTTPException(status_code=403, detail="Подарки отключены")
|
||||
|
||||
|
||||
@router.post("/create", tags=["Gifts"])
|
||||
async def create_gift_for_user(
|
||||
body: GiftCreateRequest,
|
||||
request: Request,
|
||||
preview: bool = Query(False),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
_check_gifts_enabled()
|
||||
actor = get_request_actor(request)
|
||||
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
|
||||
if billing_user_id is None:
|
||||
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
|
||||
tariff = await get_tariff_by_id(session, body.tariff_id)
|
||||
if not tariff or tariff.get("group_code") != "gifts" or not tariff.get("is_active", True):
|
||||
raise HTTPException(status_code=404, detail="Тариф не найден")
|
||||
|
||||
price = int(calculate_config_price(tariff, body.selected_device_limit, body.selected_traffic_gb))
|
||||
balance = float(await get_balance(session, billing_user_id))
|
||||
|
||||
required_amount = int(max(0, ceil(float(price) - balance)))
|
||||
|
||||
if preview:
|
||||
return GiftCreatePreviewResponse(
|
||||
ok=True,
|
||||
price_rub=price,
|
||||
balance_rub=balance,
|
||||
sufficient_funds=balance >= price,
|
||||
tariff_name=str(tariff.get("name", "")),
|
||||
duration_days=int(tariff.get("duration_days") or 0),
|
||||
)
|
||||
|
||||
if required_amount > 0:
|
||||
provider_id = str(body.provider_id or _resolve_default_web_payment_provider() or "").strip().upper()
|
||||
if not provider_id:
|
||||
raise HTTPException(status_code=503, detail="Нет доступных провайдеров оплаты")
|
||||
base_url = _resolve_public_base_url(request)
|
||||
success_url = validate_redirect_url(str(body.success_url or ""), f"{base_url}/payment-success")
|
||||
failure_url = validate_redirect_url(str(body.failure_url or ""), f"{base_url}/payment-failure")
|
||||
payment_request = PaymentLinkRequest(
|
||||
legacy_user_ref=int(billing_user_id),
|
||||
amount=required_amount,
|
||||
currency="RUB",
|
||||
provider_id=provider_id,
|
||||
success_url=success_url,
|
||||
failure_url=failure_url,
|
||||
metadata={
|
||||
"payment_flow": "gift_create",
|
||||
"tariff_id": int(body.tariff_id),
|
||||
"selected_device_limit": body.selected_device_limit,
|
||||
"selected_traffic_gb": body.selected_traffic_gb,
|
||||
"selected_price_rub": int(price),
|
||||
},
|
||||
)
|
||||
payment_result = await create_payment_link(session, payment_request)
|
||||
if not payment_result.success or not payment_result.payment_url or not payment_result.payment_id:
|
||||
raise HTTPException(status_code=400, detail=payment_result.error or "Не удалось создать ссылку оплаты")
|
||||
await create_temporary_data(
|
||||
session,
|
||||
int(billing_user_id),
|
||||
"waiting_for_payment",
|
||||
{
|
||||
"payment_flow": "gift_create",
|
||||
"tariff_id": int(body.tariff_id),
|
||||
"required_amount": int(required_amount),
|
||||
"selected_price_rub": int(price),
|
||||
"selected_device_limit": body.selected_device_limit,
|
||||
"selected_traffic_gb": body.selected_traffic_gb,
|
||||
},
|
||||
)
|
||||
return GiftCreateResponse(
|
||||
ok=True,
|
||||
message="Требуется оплата для создания подарка",
|
||||
payment_required=True,
|
||||
required_amount_rub=required_amount,
|
||||
payment_id=payment_result.payment_id,
|
||||
payment_url=payment_result.payment_url,
|
||||
)
|
||||
|
||||
from services.errors import InsufficientFundsError, NotFoundError
|
||||
|
||||
try:
|
||||
result = await service_create_gift(
|
||||
session=session,
|
||||
sender_user_ref=billing_user_id,
|
||||
tariff_id=body.tariff_id,
|
||||
selected_device_limit=body.selected_device_limit,
|
||||
selected_traffic_gb=body.selected_traffic_gb,
|
||||
selected_price_rub=price,
|
||||
)
|
||||
except InsufficientFundsError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from None
|
||||
except NotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e)) from None
|
||||
|
||||
new_balance = float(await get_balance(session, billing_user_id))
|
||||
return GiftCreateResponse(
|
||||
ok=True,
|
||||
message=f"Подарок создан — {result.tariff_name} на {result.duration_text}",
|
||||
gift_id=result.gift_id,
|
||||
site_gift_link=result.site_gift_link,
|
||||
tariff_name=result.tariff_name,
|
||||
duration_days=result.duration_days,
|
||||
price_charged=result.price_charged,
|
||||
balance_rub=new_balance,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/my", response_model=MyGiftsResponse, tags=["Gifts"])
|
||||
async def get_my_gifts(
|
||||
request: Request,
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
_check_gifts_enabled()
|
||||
actor = get_request_actor(request)
|
||||
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
|
||||
if billing_user_id is None:
|
||||
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
|
||||
base_filter = Gift.sender_user_id == billing_user_id
|
||||
total = (await session.execute(select(func.count()).select_from(Gift).where(base_filter))).scalar_one()
|
||||
|
||||
result = await session.execute(
|
||||
select(Gift).where(base_filter).order_by(Gift.created_at.desc()).limit(limit).offset(offset)
|
||||
)
|
||||
gifts = result.scalars().all()
|
||||
|
||||
tariff_ids = {g.tariff_id for g in gifts if g.tariff_id}
|
||||
tariff_map: dict[int, str] = {}
|
||||
duration_map: dict[int, int] = {}
|
||||
if tariff_ids:
|
||||
tariff_rows = await session.execute(select(Tariff).where(Tariff.id.in_(tariff_ids)))
|
||||
for t in tariff_rows.scalars().all():
|
||||
tariff_map[t.id] = t.name or ""
|
||||
duration_map[t.id] = int(t.duration_days or 0)
|
||||
|
||||
gift_ids = [g.gift_id for g in gifts]
|
||||
usages_map: dict[str, list[GiftUsageEntry]] = {gid: [] for gid in gift_ids}
|
||||
if gift_ids:
|
||||
usage_rows = await session.execute(select(GiftUsage).where(GiftUsage.gift_id.in_(gift_ids)))
|
||||
for u in usage_rows.scalars().all():
|
||||
usages_map.setdefault(u.gift_id, []).append(
|
||||
GiftUsageEntry(
|
||||
user_id=int(u.user_id),
|
||||
used_at=u.used_at.isoformat() if u.used_at else None,
|
||||
)
|
||||
)
|
||||
|
||||
items = []
|
||||
for g in gifts:
|
||||
items.append(
|
||||
MyGiftItem(
|
||||
gift_id=g.gift_id,
|
||||
tariff_name=tariff_map.get(g.tariff_id, ""),
|
||||
duration_days=duration_map.get(g.tariff_id, 0),
|
||||
price_rub=int(g.selected_price_rub or 0),
|
||||
created_at=g.created_at.isoformat() if g.created_at else None,
|
||||
expiry_time=g.expiry_time.isoformat() if g.expiry_time else None,
|
||||
is_used=bool(g.is_used),
|
||||
is_unlimited=bool(g.is_unlimited),
|
||||
max_usages=g.max_usages,
|
||||
site_gift_link=get_site_gift_link(g.gift_id),
|
||||
usages=usages_map.get(g.gift_id, []),
|
||||
)
|
||||
)
|
||||
|
||||
return MyGiftsResponse(ok=True, gifts=items, total=total, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.delete("/my/{gift_id}", response_model=dict, tags=["Gifts"])
|
||||
async def delete_my_gift(
|
||||
request: Request,
|
||||
gift_id: str = Path(...),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
"""Удаляет свой подарок."""
|
||||
actor = get_request_actor(request)
|
||||
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
|
||||
if billing_user_id is None:
|
||||
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
|
||||
result = await session.execute(select(Gift).where(Gift.gift_id == gift_id))
|
||||
gift = result.scalar_one_or_none()
|
||||
if not gift or gift.sender_user_id != billing_user_id:
|
||||
raise HTTPException(status_code=404, detail="Подарок не найден")
|
||||
await session.execute(delete(GiftUsage).where(GiftUsage.gift_id == gift_id))
|
||||
await session.delete(gift)
|
||||
await session.commit()
|
||||
return {"ok": True, "message": "Подарок удалён"}
|
||||
|
||||
|
||||
gift_router = generate_crud_router(
|
||||
model=Gift,
|
||||
schema_response=GiftResponse,
|
||||
@@ -21,6 +258,35 @@ gift_router = generate_crud_router(
|
||||
router.include_router(gift_router, prefix="", tags=["Gifts"])
|
||||
|
||||
|
||||
@router.post("/redeem", response_model=GiftRedeemResponse, tags=["Gifts"])
|
||||
async def redeem_gift(
|
||||
body: GiftRedeemRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
_check_gifts_enabled()
|
||||
actor = get_request_actor(request)
|
||||
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
|
||||
if billing_user_id is None:
|
||||
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
try:
|
||||
result = await service_redeem_gift(session, body.gift_code, billing_user_id)
|
||||
except ValidationError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from None
|
||||
except NotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e)) from None
|
||||
except Exception:
|
||||
raise HTTPException(status_code=500, detail="Не удалось активировать подарок") from None
|
||||
return GiftRedeemResponse(
|
||||
ok=True,
|
||||
message=result.message,
|
||||
gift_id=result.gift_id,
|
||||
tariff_id=result.tariff_id,
|
||||
duration_days=result.duration_days,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/by_tg_id/{tg_id}", response_model=list[GiftResponse], tags=["Gifts"])
|
||||
async def get_gifts_by_tg_id(
|
||||
tg_id: int = Path(...),
|
||||
@@ -28,7 +294,10 @@ async def get_gifts_by_tg_id(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Список подарков по tg_id отправителя."""
|
||||
result = await session.execute(select(Gift).where(Gift.sender_tg_id == tg_id))
|
||||
u = await resolve_user_optional(session, tg_id)
|
||||
if u is None:
|
||||
raise HTTPException(status_code=404, detail="Gifts not found")
|
||||
result = await session.execute(select(Gift).where(Gift.sender_user_id == u.id))
|
||||
gifts = result.scalars().all()
|
||||
if not gifts:
|
||||
raise HTTPException(status_code=404, detail="Gifts not found")
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
from ._common import router, user_router
|
||||
from . import admin, user # noqa: F401 — import triggers endpoint registration
|
||||
|
||||
__all__ = ["router", "user_router"]
|
||||
@@ -0,0 +1,279 @@
|
||||
import asyncio
|
||||
import re
|
||||
|
||||
from base64 import b64encode
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
from math import ceil
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import qrcode
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Request, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import (
|
||||
get_request_actor,
|
||||
get_session,
|
||||
validate_redirect_url,
|
||||
verify_identity_admin,
|
||||
verify_identity_token,
|
||||
)
|
||||
from api.v2.base_crud import generate_crud_router
|
||||
from api.v2.routes.coupon_pricing import resolve_percent_coupon_pricing
|
||||
from api.v2.schemas import KeyBase, KeyCreateRequest, KeyResponse, KeyUpdate
|
||||
from api.v2.schemas.web_public import (
|
||||
AccountKeyActionResponse,
|
||||
AccountKeyActionsAvailability,
|
||||
AccountKeyActionsConfigResponse,
|
||||
AccountKeyAddonOptionResponse,
|
||||
AccountKeyAddonsPreviewRequest,
|
||||
AccountKeyAddonsPreviewResponse,
|
||||
AccountKeyAliasUpdateRequest,
|
||||
AccountKeyApplyAddonsResponse,
|
||||
AccountKeyChangeLocationRequest,
|
||||
AccountKeyChangeLocationResponse,
|
||||
AccountKeyDetailsResponse,
|
||||
AccountKeyLocationOptionResponse,
|
||||
AccountKeyLocationsResponse,
|
||||
AccountKeyQrResponse,
|
||||
AccountKeyRenewRequest,
|
||||
AccountKeyRenewResponse,
|
||||
AccountKeyResetHwidResponse,
|
||||
AccountKeyResponse,
|
||||
)
|
||||
from config import (
|
||||
ENABLE_DELETE_KEY_BUTTON,
|
||||
HWID_RESET_BUTTON,
|
||||
INSTRUCTIONS_BUTTON,
|
||||
QRCODE,
|
||||
REMNAWAVE_LOGIN,
|
||||
REMNAWAVE_PASSWORD,
|
||||
USE_COUNTRY_SELECTION,
|
||||
)
|
||||
from core.bootstrap import BUTTONS_CONFIG, MODES_CONFIG, PAYMENTS_CONFIG, TARIFFS_CONFIG
|
||||
from core.settings.tariffs_config import normalize_tariff_config
|
||||
from database import (
|
||||
check_server_name_by_cluster,
|
||||
filter_cluster_by_subgroup,
|
||||
get_balance,
|
||||
get_key_details,
|
||||
get_keys,
|
||||
get_tariff_by_id,
|
||||
identities as idb,
|
||||
save_key_config_with_mode,
|
||||
update_balance,
|
||||
)
|
||||
from database.access.resolution import resolve_user_optional
|
||||
from database.coupons import mark_coupon_used
|
||||
from database.models import Key, Server, ServerSpecialgroup, Tariff
|
||||
from database.temporary_data import create_temporary_data
|
||||
from handlers.buttons import CONNECT_DEVICE, ROUTER_BUTTON, TV_BUTTON
|
||||
from handlers.keys.key_view import build_key_view_payload
|
||||
from handlers.tariffs.addons.key_addons_pack import calc_pack_full_price_rub, get_pack_flags
|
||||
from handlers.tariffs.addons.utils import calc_remaining_ratio_seconds, is_not_downgrade
|
||||
from handlers.utils import ALLOWED_GROUP_CODES, is_full_remnawave_cluster
|
||||
from logger import logger
|
||||
from panels._3xui import delete_client, get_xui_instance
|
||||
from panels.remnawave import RemnawaveAPI, get_vless_link_for_remnawave_by_username
|
||||
from panels.remnawave_runtime import get_remnawave_profile, invalidate_remnawave_profile, with_remnawave_api
|
||||
from services.operations import (
|
||||
create_client_on_server,
|
||||
create_key_on_cluster,
|
||||
delete_key_from_cluster,
|
||||
renew_key_in_cluster,
|
||||
)
|
||||
from services.operations.aggregated_links import make_aggregated_link
|
||||
from services.payments.payment_links import PaymentLinkRequest, create_payment_link
|
||||
from services.payments.providers import WEB_LINK_PROVIDER_IDS
|
||||
from services.tariffs import calculate_config_price
|
||||
from services.tariffs.tariff_display import GB, get_effective_limits_for_key, get_key_tariff_addons_state
|
||||
|
||||
|
||||
|
||||
router = generate_crud_router(
|
||||
model=Key,
|
||||
schema_response=KeyResponse,
|
||||
schema_create=KeyBase,
|
||||
schema_update=KeyUpdate,
|
||||
identifier_field="tg_id",
|
||||
extra_get_by_email=True,
|
||||
enabled_methods=["get_all", "get_one", "get_by_email", "get_all_by_field"],
|
||||
)
|
||||
user_router = APIRouter()
|
||||
|
||||
|
||||
|
||||
|
||||
def _key_actions_config() -> AccountKeyActionsConfigResponse:
|
||||
addons_mode = str(TARIFFS_CONFIG.get("KEY_ADDONS_PACK_MODE", "") or "").strip().lower()
|
||||
if addons_mode not in {"", "traffic", "devices", "all"}:
|
||||
addons_mode = ""
|
||||
addons_enabled_default = addons_mode in {"", "traffic", "devices", "all"}
|
||||
return AccountKeyActionsConfigResponse(
|
||||
renew_enabled=True,
|
||||
delete_enabled=bool(BUTTONS_CONFIG.get("DELETE_KEY_BUTTON_ENABLE", ENABLE_DELETE_KEY_BUTTON)),
|
||||
qr_enabled=bool(BUTTONS_CONFIG.get("QRCODE_BUTTON_ENABLE", QRCODE)),
|
||||
hwid_reset_enabled=bool(BUTTONS_CONFIG.get("HWID_RESET_BUTTON_ENABLE", HWID_RESET_BUTTON)),
|
||||
country_change_enabled=bool(MODES_CONFIG.get("COUNTRY_SELECTION_ENABLED", USE_COUNTRY_SELECTION)),
|
||||
instructions_enabled=bool(BUTTONS_CONFIG.get("INSTRUCTIONS_BUTTON_ENABLE", INSTRUCTIONS_BUTTON)),
|
||||
addons_enabled=addons_enabled_default,
|
||||
addons_mode=addons_mode,
|
||||
tv_connect_enabled=bool(BUTTONS_CONFIG.get("ANDROID_TV_BUTTON_ENABLE")),
|
||||
)
|
||||
|
||||
|
||||
def _extract_key_actions_from_markup(markup) -> AccountKeyActionsAvailability:
|
||||
actions = AccountKeyActionsAvailability()
|
||||
rows = getattr(markup, "inline_keyboard", None) or []
|
||||
for row in rows:
|
||||
for button in row:
|
||||
callback_data = str(getattr(button, "callback_data", "") or "")
|
||||
text = str(getattr(button, "text", "") or "")
|
||||
has_url = bool(getattr(button, "url", None))
|
||||
has_web_app = bool(getattr(button, "web_app", None))
|
||||
if callback_data.startswith("connect_router|") or text == ROUTER_BUTTON:
|
||||
actions.can_connect_router = True
|
||||
if callback_data.startswith("connect_tv|") or text == TV_BUTTON:
|
||||
actions.can_connect_tv = True
|
||||
if callback_data.startswith("connect_device|") or (text == CONNECT_DEVICE and (has_url or has_web_app)):
|
||||
actions.can_connect_device = True
|
||||
if callback_data.startswith("renew_key|"):
|
||||
actions.can_renew = True
|
||||
if callback_data.startswith("key_addons|"):
|
||||
actions.can_addons = True
|
||||
if callback_data.startswith("reset_hwid|"):
|
||||
actions.can_reset_hwid = True
|
||||
if callback_data.startswith("show_qr|"):
|
||||
actions.can_qr = True
|
||||
if callback_data.startswith("delete_key|"):
|
||||
actions.can_delete = True
|
||||
if callback_data.startswith("change_location|"):
|
||||
actions.can_change_location = True
|
||||
return actions
|
||||
|
||||
|
||||
async def _resolve_available_location_servers(session: AsyncSession, db_key: Key) -> list[str]:
|
||||
current_server = str(getattr(db_key, "server_id", "") or "")
|
||||
if not current_server:
|
||||
return []
|
||||
cluster_info = await check_server_name_by_cluster(session, current_server)
|
||||
if not cluster_info:
|
||||
return []
|
||||
cluster_name = str(cluster_info.get("cluster_name") or "")
|
||||
if not cluster_name:
|
||||
return []
|
||||
q = (
|
||||
select(
|
||||
Server.id,
|
||||
Server.server_name,
|
||||
Server.api_url,
|
||||
Server.panel_type,
|
||||
Server.enabled,
|
||||
Server.max_keys,
|
||||
)
|
||||
.where(Server.cluster_name == cluster_name)
|
||||
.where(Server.server_name != current_server)
|
||||
)
|
||||
servers = [dict(m) for m in (await session.execute(q)).mappings().all()]
|
||||
if not servers:
|
||||
return []
|
||||
server_ids = [s["id"] for s in servers if s.get("id") is not None]
|
||||
groups_map: dict[int, list[str]] = {}
|
||||
if server_ids:
|
||||
r = await session.execute(
|
||||
select(ServerSpecialgroup.server_id, ServerSpecialgroup.group_code).where(
|
||||
ServerSpecialgroup.server_id.in_(server_ids)
|
||||
)
|
||||
)
|
||||
for sid, gc in r.all():
|
||||
groups_map.setdefault(int(sid), []).append(gc)
|
||||
for server in servers:
|
||||
sid_raw = server.get("id")
|
||||
sid = int(sid_raw) if sid_raw is not None else -1
|
||||
server["special_groups"] = [g for g in groups_map.get(sid, []) if g in ALLOWED_GROUP_CODES]
|
||||
key_tariff_id = getattr(db_key, "tariff_id", None)
|
||||
subgroup_title = None
|
||||
tariff_dict = None
|
||||
if key_tariff_id:
|
||||
tariff_dict = await get_tariff_by_id(session, int(key_tariff_id))
|
||||
if tariff_dict:
|
||||
subgroup_title = tariff_dict.get("subgroup_title")
|
||||
available_servers = [s for s in servers if bool(s.get("enabled", True))]
|
||||
if subgroup_title and available_servers:
|
||||
filtered_servers = await filter_cluster_by_subgroup(
|
||||
session=session,
|
||||
cluster=available_servers,
|
||||
target_subgroup=str(subgroup_title).strip(),
|
||||
cluster_id=cluster_name,
|
||||
tariff_id=int(key_tariff_id) if key_tariff_id else None,
|
||||
)
|
||||
if filtered_servers:
|
||||
available_servers = filtered_servers
|
||||
else:
|
||||
available_servers = []
|
||||
if available_servers and tariff_dict:
|
||||
special = None
|
||||
gc = str(tariff_dict.get("group_code") or "").lower()
|
||||
if gc and gc in ALLOWED_GROUP_CODES:
|
||||
special = gc
|
||||
if special:
|
||||
bound_servers = [s for s in available_servers if special in (s.get("special_groups") or [])]
|
||||
if bound_servers:
|
||||
available_servers = bound_servers
|
||||
names = sorted(
|
||||
{
|
||||
str(s.get("server_name") or "").strip()
|
||||
for s in available_servers
|
||||
if str(s.get("server_name") or "").strip()
|
||||
}
|
||||
)
|
||||
return names
|
||||
|
||||
|
||||
async def _resolve_billing_user_id(request: Request, identity, session: AsyncSession) -> int:
|
||||
actor = get_request_actor(request)
|
||||
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
|
||||
if billing_user_id is None:
|
||||
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
return int(billing_user_id)
|
||||
|
||||
|
||||
def _resolve_public_base_url(request: Request) -> str:
|
||||
origin = str(request.headers.get("origin") or "").strip()
|
||||
if origin.startswith(("http://", "https://")):
|
||||
return origin.rstrip("/")
|
||||
referer = str(request.headers.get("referer") or request.headers.get("referrer") or "").strip()
|
||||
if referer.startswith(("http://", "https://")):
|
||||
parsed = urlsplit(referer)
|
||||
if parsed.scheme and parsed.netloc:
|
||||
return f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
|
||||
forwarded_host = str(request.headers.get("x-forwarded-host") or "").strip()
|
||||
host = forwarded_host or str(request.headers.get("host") or "").strip()
|
||||
forwarded_proto = str(request.headers.get("x-forwarded-proto") or "").split(",", 1)[0].strip().lower()
|
||||
scheme = forwarded_proto if forwarded_proto in {"http", "https"} else request.url.scheme
|
||||
if host:
|
||||
return f"{scheme}://{host}".rstrip("/")
|
||||
return str(request.base_url).rstrip("/")
|
||||
|
||||
|
||||
def _resolve_default_web_payment_provider() -> str | None:
|
||||
for provider_id in WEB_LINK_PROVIDER_IDS:
|
||||
if bool(PAYMENTS_CONFIG.get(provider_id)):
|
||||
return provider_id
|
||||
return WEB_LINK_PROVIDER_IDS[0] if WEB_LINK_PROVIDER_IDS else None
|
||||
|
||||
|
||||
def _normalize_expiry_ms(raw_value: int | float | None) -> int:
|
||||
if not raw_value:
|
||||
return 0
|
||||
value = int(raw_value)
|
||||
if value > 10**13:
|
||||
value //= 1000
|
||||
elif value < 10**10:
|
||||
value *= 1000
|
||||
return value
|
||||
|
||||
|
||||
@@ -1,26 +1,5 @@
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import Body, Depends, HTTPException, Path, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_identity_admin
|
||||
from api.v2.schemas import KeyBase, KeyCreateRequest, KeyResponse, KeyUpdate
|
||||
from api.v2.base_crud import generate_crud_router
|
||||
from database.models import Key, Tariff
|
||||
from handlers.keys.operations import create_key_on_cluster, delete_key_from_cluster, renew_key_in_cluster
|
||||
from logger import logger
|
||||
|
||||
router = generate_crud_router(
|
||||
model=Key,
|
||||
schema_response=KeyResponse,
|
||||
schema_create=KeyBase,
|
||||
schema_update=KeyUpdate,
|
||||
identifier_field="tg_id",
|
||||
extra_get_by_email=True,
|
||||
enabled_methods=["get_all", "get_one", "get_by_email", "get_all_by_field"],
|
||||
)
|
||||
|
||||
from ._common import * # noqa: F401,F403
|
||||
from ._common import router, user_router # noqa: F401
|
||||
|
||||
@router.delete("/by_email/{email}", response_model=dict)
|
||||
async def delete_key_by_email(
|
||||
@@ -60,7 +39,10 @@ async def get_router_keys_by_tg_id(
|
||||
tariff_ids = [row[0] for row in tariffs_result.all()]
|
||||
if not tariff_ids:
|
||||
return []
|
||||
keys_result = await session.execute(select(Key).where(Key.tg_id == tg_id, Key.tariff_id.in_(tariff_ids)))
|
||||
u = await resolve_user_optional(session, tg_id)
|
||||
if u is None:
|
||||
return []
|
||||
keys_result = await session.execute(select(Key).where(Key.user_id == u.id, Key.tariff_id.in_(tariff_ids)))
|
||||
return keys_result.scalars().all()
|
||||
|
||||
|
||||
@@ -132,3 +114,4 @@ async def create_key_api(
|
||||
except Exception as e:
|
||||
logger.error(f"[API] Ошибка при создании ключа: {e}")
|
||||
raise HTTPException(status_code=500, detail="Ошибка при создании ключа")
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from . import addons, core, hwid, location, renew # noqa: F401 — trigger registration
|
||||
@@ -0,0 +1,619 @@
|
||||
"""User-facing key endpoints (/api/keys/*).
|
||||
|
||||
Регистрирует эндпоинты на ``user_router`` из ``_common``. Импорт этого модуля
|
||||
из ``__init__.py`` запускает регистрацию декораторов.
|
||||
"""
|
||||
|
||||
from .._common import * # noqa: F401,F403 — подтягиваем все имена для endpoints
|
||||
from .._common import (
|
||||
_key_actions_config,
|
||||
_resolve_available_location_servers,
|
||||
_resolve_billing_user_id,
|
||||
_resolve_default_web_payment_provider,
|
||||
_resolve_public_base_url,
|
||||
_normalize_expiry_ms,
|
||||
router,
|
||||
user_router,
|
||||
)
|
||||
|
||||
|
||||
@user_router.get("/{client_id}/addons-preview", response_model=AccountKeyAddonsPreviewResponse)
|
||||
async def user_key_addons_preview(
|
||||
client_id: str,
|
||||
request: Request,
|
||||
selected_device_limit: int | None = Query(None),
|
||||
selected_traffic_gb: int | None = Query(None),
|
||||
include_device: bool | None = Query(None),
|
||||
include_traffic: bool | None = Query(None),
|
||||
coupon_code: str | None = Query(None),
|
||||
force_web: bool = Query(False),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
actions = _key_actions_config()
|
||||
if not force_web and not actions.addons_enabled:
|
||||
raise HTTPException(status_code=403, detail="Доп. опции отключены в настройках")
|
||||
billing_user_id = await _resolve_billing_user_id(request, identity, session)
|
||||
db_key = (
|
||||
await session.execute(
|
||||
select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if db_key is None:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
tariff_id = getattr(db_key, "tariff_id", None)
|
||||
if not tariff_id:
|
||||
raise HTTPException(status_code=400, detail="Для подписки не назначен тариф")
|
||||
tariff = await get_tariff_by_id(session, int(tariff_id))
|
||||
if not tariff:
|
||||
raise HTTPException(status_code=404, detail="Тариф не найден")
|
||||
key_details = await get_key_details(session, str(getattr(db_key, "email", "") or ""))
|
||||
if not key_details:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
(
|
||||
_tariff_name,
|
||||
_subgroup_title,
|
||||
_traffic_limit_gb,
|
||||
_device_limit,
|
||||
_panel,
|
||||
is_tariff_configurable,
|
||||
addons_devices_enabled,
|
||||
addons_traffic_enabled,
|
||||
) = await get_key_tariff_addons_state(session=session, key_record=key_details, db_key=db_key)
|
||||
if not is_tariff_configurable:
|
||||
raise HTTPException(status_code=400, detail="Тариф не поддерживает доп. опции")
|
||||
cfg = normalize_tariff_config(tariff)
|
||||
raw_device_options = cfg.get("device_options") or tariff.get("device_options") or []
|
||||
raw_traffic_options = cfg.get("traffic_options_gb") or tariff.get("traffic_options_gb") or []
|
||||
device_options: list[int] = []
|
||||
for value in raw_device_options:
|
||||
try:
|
||||
device_options.append(int(value))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
traffic_options: list[int] = []
|
||||
for value in raw_traffic_options:
|
||||
try:
|
||||
traffic_options.append(int(value))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
device_options = sorted(set(device_options), key=lambda val: (int(val == 0), val))
|
||||
traffic_options = sorted(set(traffic_options), key=lambda val: (int(val == 0), val))
|
||||
has_device_option = bool(device_options) and bool(addons_devices_enabled)
|
||||
has_traffic_option = bool(traffic_options) and bool(addons_traffic_enabled)
|
||||
pack_devices, pack_traffic, pack_mode = get_pack_flags()
|
||||
if pack_mode:
|
||||
has_device_option = has_device_option and bool(pack_devices)
|
||||
has_traffic_option = has_traffic_option and bool(pack_traffic)
|
||||
if not has_device_option:
|
||||
device_options = []
|
||||
if not has_traffic_option:
|
||||
traffic_options = []
|
||||
if not has_device_option and not has_traffic_option:
|
||||
raise HTTPException(status_code=400, detail="Доп. опции для этой подписки недоступны")
|
||||
selected_device_limit_db = key_details.get("selected_device_limit")
|
||||
selected_traffic_limit_db = key_details.get("selected_traffic_limit")
|
||||
current_device_limit_db = key_details.get("current_device_limit")
|
||||
current_traffic_limit_db = key_details.get("current_traffic_limit")
|
||||
base_devices = tariff.get("device_limit")
|
||||
base_devices = int(base_devices) if base_devices is not None else None
|
||||
base_traffic_bytes = tariff.get("traffic_limit")
|
||||
base_traffic_gb_from_tariff = int(base_traffic_bytes / GB) if base_traffic_bytes else None
|
||||
current_device_limit = (
|
||||
int(current_device_limit_db)
|
||||
if current_device_limit_db is not None
|
||||
else (int(selected_device_limit_db) if selected_device_limit_db is not None else base_devices)
|
||||
)
|
||||
current_traffic_gb = (
|
||||
int(current_traffic_limit_db)
|
||||
if current_traffic_limit_db is not None
|
||||
else (int(selected_traffic_limit_db) if selected_traffic_limit_db is not None else base_traffic_gb_from_tariff)
|
||||
)
|
||||
if pack_mode and current_device_limit is not None and int(current_device_limit) == 0:
|
||||
has_device_option = False
|
||||
device_options = []
|
||||
if pack_mode and current_traffic_gb is not None and int(current_traffic_gb) == 0:
|
||||
has_traffic_option = False
|
||||
traffic_options = []
|
||||
if not has_device_option and not has_traffic_option:
|
||||
raise HTTPException(status_code=400, detail="Доп. опции для этой подписки недоступны")
|
||||
if pack_mode:
|
||||
include_device_effective = bool(include_device) if include_device is not None else selected_device_limit is not None
|
||||
include_traffic_effective = bool(include_traffic) if include_traffic is not None else selected_traffic_gb is not None
|
||||
selected_device = (
|
||||
selected_device_limit
|
||||
if selected_device_limit is not None
|
||||
else None
|
||||
)
|
||||
selected_traffic = (
|
||||
selected_traffic_gb
|
||||
if selected_traffic_gb is not None
|
||||
else None
|
||||
)
|
||||
else:
|
||||
include_device_effective = has_device_option
|
||||
include_traffic_effective = has_traffic_option
|
||||
selected_device = selected_device_limit if selected_device_limit is not None else current_device_limit
|
||||
selected_traffic = selected_traffic_gb if selected_traffic_gb is not None else current_traffic_gb
|
||||
if has_device_option and include_device_effective and selected_device is not None and int(selected_device) not in device_options:
|
||||
raise HTTPException(status_code=400, detail="Выбранный пакет устройств недоступен")
|
||||
if has_traffic_option and include_traffic_effective and selected_traffic is not None and int(selected_traffic) not in traffic_options:
|
||||
raise HTTPException(status_code=400, detail="Выбранный пакет трафика недоступен")
|
||||
current_devices_for_price = int(current_device_limit) if current_device_limit is not None else None
|
||||
current_traffic_for_price = int(current_traffic_gb) if current_traffic_gb is not None else None
|
||||
base_price_for_current = int(
|
||||
calculate_config_price(
|
||||
tariff=tariff,
|
||||
selected_device_limit=current_devices_for_price,
|
||||
selected_traffic_gb=current_traffic_for_price,
|
||||
)
|
||||
)
|
||||
if pack_mode:
|
||||
diff_full = int(
|
||||
calc_pack_full_price_rub(
|
||||
tariff=tariff,
|
||||
has_device_option=bool(has_device_option and include_device_effective),
|
||||
has_traffic_option=bool(has_traffic_option and include_traffic_effective),
|
||||
selected_devices=int(selected_device) if has_device_option and include_device_effective and selected_device is not None else None,
|
||||
selected_traffic_gb=int(selected_traffic) if has_traffic_option and include_traffic_effective and selected_traffic is not None else None,
|
||||
)
|
||||
)
|
||||
recalc_enabled = bool(
|
||||
MODES_CONFIG.get(
|
||||
"KEY_ADDONS_RECALC_PRICE",
|
||||
TARIFFS_CONFIG.get("KEY_ADDONS_RECALC_PRICE", False),
|
||||
)
|
||||
)
|
||||
if recalc_enabled:
|
||||
remaining_seconds, total_seconds = calc_remaining_ratio_seconds(
|
||||
key_details.get("expiry_time"),
|
||||
tariff,
|
||||
)
|
||||
extra_price_rub = int((diff_full * remaining_seconds + total_seconds - 1) // total_seconds)
|
||||
else:
|
||||
extra_price_rub = diff_full
|
||||
total_price_rub = int(base_price_for_current + diff_full)
|
||||
else:
|
||||
total_price_rub = int(
|
||||
calculate_config_price(
|
||||
tariff=tariff,
|
||||
selected_device_limit=int(selected_device) if has_device_option and include_device_effective and selected_device is not None else None,
|
||||
selected_traffic_gb=int(selected_traffic) if has_traffic_option and include_traffic_effective and selected_traffic is not None else None,
|
||||
)
|
||||
)
|
||||
extra_price_rub = int(max(0, total_price_rub - base_price_for_current))
|
||||
final_extra_price_rub, discount_rub, _coupon_id, applied_coupon_code = await resolve_percent_coupon_pricing(
|
||||
session=session,
|
||||
billing_user_id=int(billing_user_id),
|
||||
base_price_rub=int(max(0, extra_price_rub)),
|
||||
coupon_code=coupon_code,
|
||||
)
|
||||
return AccountKeyAddonsPreviewResponse(
|
||||
client_id=str(getattr(db_key, "client_id", "") or ""),
|
||||
tariff_id=int(tariff_id),
|
||||
addons_mode=str(pack_mode or ""),
|
||||
has_device_option=bool(has_device_option),
|
||||
has_traffic_option=bool(has_traffic_option),
|
||||
current_device_limit=int(current_device_limit) if current_device_limit is not None else None,
|
||||
current_traffic_gb=int(current_traffic_gb) if current_traffic_gb is not None else None,
|
||||
selected_device_limit=int(selected_device) if has_device_option and include_device_effective and selected_device is not None else None,
|
||||
selected_traffic_gb=int(selected_traffic) if has_traffic_option and include_traffic_effective and selected_traffic is not None else None,
|
||||
device_options=[
|
||||
AccountKeyAddonOptionResponse(
|
||||
value=int(val),
|
||||
label=(
|
||||
"Безлимит устройств"
|
||||
if int(val) <= 0
|
||||
else (f"+{int(val)} устройств" if pack_mode else f"{int(val)} устройств")
|
||||
),
|
||||
)
|
||||
for val in device_options
|
||||
],
|
||||
traffic_options=[
|
||||
AccountKeyAddonOptionResponse(
|
||||
value=int(val),
|
||||
label=(
|
||||
"Безлимит трафика"
|
||||
if int(val) <= 0
|
||||
else (f"+{int(val)} ГБ" if pack_mode else f"{int(val)} ГБ")
|
||||
),
|
||||
)
|
||||
for val in traffic_options
|
||||
],
|
||||
total_price_rub=int(total_price_rub),
|
||||
extra_price_rub=int(max(0, extra_price_rub)),
|
||||
discount_rub=int(discount_rub),
|
||||
final_price_rub=int(max(0, final_extra_price_rub)),
|
||||
applied_coupon_code=applied_coupon_code,
|
||||
balance_rub=float(await get_balance(session, int(billing_user_id))),
|
||||
)
|
||||
|
||||
|
||||
@user_router.post("/{client_id}/apply-addons", response_model=AccountKeyApplyAddonsResponse)
|
||||
async def user_key_apply_addons(
|
||||
client_id: str,
|
||||
body: AccountKeyAddonsPreviewRequest,
|
||||
request: Request,
|
||||
force_web: bool = Query(False),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
actions = _key_actions_config()
|
||||
if not force_web and not actions.addons_enabled:
|
||||
raise HTTPException(status_code=403, detail="Доп. опции отключены в настройках")
|
||||
billing_user_id = await _resolve_billing_user_id(request, identity, session)
|
||||
db_key = (
|
||||
await session.execute(
|
||||
select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if db_key is None:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
tariff_id = getattr(db_key, "tariff_id", None)
|
||||
if not tariff_id:
|
||||
raise HTTPException(status_code=400, detail="Для подписки не назначен тариф")
|
||||
tariff = await get_tariff_by_id(session, int(tariff_id))
|
||||
if not tariff:
|
||||
raise HTTPException(status_code=404, detail="Тариф не найден")
|
||||
key_details = await get_key_details(session, str(getattr(db_key, "email", "") or ""))
|
||||
if not key_details:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
(
|
||||
_tariff_name,
|
||||
_subgroup_title,
|
||||
_traffic_limit_gb,
|
||||
_device_limit,
|
||||
_panel,
|
||||
is_tariff_configurable,
|
||||
addons_devices_enabled,
|
||||
addons_traffic_enabled,
|
||||
) = await get_key_tariff_addons_state(session=session, key_record=key_details, db_key=db_key)
|
||||
if not is_tariff_configurable:
|
||||
raise HTTPException(status_code=400, detail="Тариф не поддерживает доп. опции")
|
||||
cfg = normalize_tariff_config(tariff)
|
||||
raw_device_options = cfg.get("device_options") or tariff.get("device_options") or []
|
||||
raw_traffic_options = cfg.get("traffic_options_gb") or tariff.get("traffic_options_gb") or []
|
||||
device_options: list[int] = []
|
||||
for value in raw_device_options:
|
||||
try:
|
||||
device_options.append(int(value))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
traffic_options: list[int] = []
|
||||
for value in raw_traffic_options:
|
||||
try:
|
||||
traffic_options.append(int(value))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
device_options = sorted(set(device_options), key=lambda val: (int(val == 0), val))
|
||||
traffic_options = sorted(set(traffic_options), key=lambda val: (int(val == 0), val))
|
||||
has_device_option = bool(device_options) and bool(addons_devices_enabled)
|
||||
has_traffic_option = bool(traffic_options) and bool(addons_traffic_enabled)
|
||||
pack_devices, pack_traffic, pack_mode = get_pack_flags()
|
||||
if pack_mode:
|
||||
has_device_option = has_device_option and bool(pack_devices)
|
||||
has_traffic_option = has_traffic_option and bool(pack_traffic)
|
||||
if not has_device_option:
|
||||
device_options = []
|
||||
if not has_traffic_option:
|
||||
traffic_options = []
|
||||
if not has_device_option and not has_traffic_option:
|
||||
raise HTTPException(status_code=400, detail="Доп. опции для этой подписки недоступны")
|
||||
selected_device_limit_db = key_details.get("selected_device_limit")
|
||||
selected_traffic_limit_db = key_details.get("selected_traffic_limit")
|
||||
current_device_limit_db = key_details.get("current_device_limit")
|
||||
current_traffic_limit_db = key_details.get("current_traffic_limit")
|
||||
base_devices = tariff.get("device_limit")
|
||||
base_devices = int(base_devices) if base_devices is not None else None
|
||||
base_traffic_bytes = tariff.get("traffic_limit")
|
||||
base_traffic_gb_from_tariff = int(base_traffic_bytes / GB) if base_traffic_bytes else None
|
||||
current_device_limit = (
|
||||
int(current_device_limit_db)
|
||||
if current_device_limit_db is not None
|
||||
else (int(selected_device_limit_db) if selected_device_limit_db is not None else base_devices)
|
||||
)
|
||||
current_traffic_gb = (
|
||||
int(current_traffic_limit_db)
|
||||
if current_traffic_limit_db is not None
|
||||
else (int(selected_traffic_limit_db) if selected_traffic_limit_db is not None else base_traffic_gb_from_tariff)
|
||||
)
|
||||
if pack_mode and current_device_limit is not None and int(current_device_limit) == 0:
|
||||
has_device_option = False
|
||||
device_options = []
|
||||
if pack_mode and current_traffic_gb is not None and int(current_traffic_gb) == 0:
|
||||
has_traffic_option = False
|
||||
traffic_options = []
|
||||
if not has_device_option and not has_traffic_option:
|
||||
raise HTTPException(status_code=400, detail="Доп. опции для этой подписки недоступны")
|
||||
if pack_mode:
|
||||
include_device_effective = (
|
||||
bool(body.include_device) if body.include_device is not None else body.selected_device_limit is not None
|
||||
)
|
||||
include_traffic_effective = (
|
||||
bool(body.include_traffic) if body.include_traffic is not None else body.selected_traffic_gb is not None
|
||||
)
|
||||
selected_device = (
|
||||
body.selected_device_limit
|
||||
if body.selected_device_limit is not None
|
||||
else None
|
||||
)
|
||||
selected_traffic = (
|
||||
body.selected_traffic_gb
|
||||
if body.selected_traffic_gb is not None
|
||||
else None
|
||||
)
|
||||
else:
|
||||
include_device_effective = has_device_option
|
||||
include_traffic_effective = has_traffic_option
|
||||
selected_device = body.selected_device_limit if body.selected_device_limit is not None else current_device_limit
|
||||
selected_traffic = body.selected_traffic_gb if body.selected_traffic_gb is not None else current_traffic_gb
|
||||
if has_device_option and include_device_effective and selected_device is not None and int(selected_device) not in device_options:
|
||||
raise HTTPException(status_code=400, detail="Выбранный пакет устройств недоступен")
|
||||
if has_traffic_option and include_traffic_effective and selected_traffic is not None and int(selected_traffic) not in traffic_options:
|
||||
raise HTTPException(status_code=400, detail="Выбранный пакет трафика недоступен")
|
||||
current_devices_for_price = int(current_device_limit) if current_device_limit is not None else None
|
||||
current_traffic_for_price = int(current_traffic_gb) if current_traffic_gb is not None else None
|
||||
base_price_for_current = int(
|
||||
calculate_config_price(
|
||||
tariff=tariff,
|
||||
selected_device_limit=current_devices_for_price,
|
||||
selected_traffic_gb=current_traffic_for_price,
|
||||
)
|
||||
)
|
||||
total_price_after_purchase = base_price_for_current
|
||||
if pack_mode:
|
||||
diff_full = int(
|
||||
calc_pack_full_price_rub(
|
||||
tariff=tariff,
|
||||
has_device_option=bool(has_device_option and include_device_effective),
|
||||
has_traffic_option=bool(has_traffic_option and include_traffic_effective),
|
||||
selected_devices=int(selected_device) if has_device_option and include_device_effective and selected_device is not None else None,
|
||||
selected_traffic_gb=int(selected_traffic) if has_traffic_option and include_traffic_effective and selected_traffic is not None else None,
|
||||
)
|
||||
)
|
||||
recalc_enabled = bool(
|
||||
MODES_CONFIG.get(
|
||||
"KEY_ADDONS_RECALC_PRICE",
|
||||
TARIFFS_CONFIG.get("KEY_ADDONS_RECALC_PRICE", False),
|
||||
)
|
||||
)
|
||||
if recalc_enabled:
|
||||
remaining_seconds, total_seconds = calc_remaining_ratio_seconds(
|
||||
key_details.get("expiry_time"),
|
||||
tariff,
|
||||
)
|
||||
extra_price_rub = int((diff_full * remaining_seconds + total_seconds - 1) // total_seconds)
|
||||
else:
|
||||
extra_price_rub = diff_full
|
||||
total_price_after_purchase = int(base_price_for_current + diff_full)
|
||||
else:
|
||||
selected_total_price = int(
|
||||
calculate_config_price(
|
||||
tariff=tariff,
|
||||
selected_device_limit=int(selected_device) if has_device_option and selected_device is not None else None,
|
||||
selected_traffic_gb=int(selected_traffic) if has_traffic_option and selected_traffic is not None else None,
|
||||
)
|
||||
)
|
||||
extra_price_rub = int(max(0, selected_total_price - base_price_for_current))
|
||||
total_price_after_purchase = selected_total_price
|
||||
allow_downgrade = bool(TARIFFS_CONFIG.get("ALLOW_DOWNGRADE", True))
|
||||
device_downgrade = (
|
||||
allow_downgrade
|
||||
and has_device_option
|
||||
and current_device_limit is not None
|
||||
and selected_device is not None
|
||||
and not is_not_downgrade(current_device_limit, selected_device)
|
||||
)
|
||||
traffic_downgrade = (
|
||||
allow_downgrade
|
||||
and has_traffic_option
|
||||
and current_traffic_gb is not None
|
||||
and selected_traffic is not None
|
||||
and not is_not_downgrade(current_traffic_gb, selected_traffic)
|
||||
)
|
||||
if device_downgrade or traffic_downgrade:
|
||||
raise HTTPException(status_code=400, detail="Снижение параметров через сайт пока не поддерживается")
|
||||
final_extra_price_rub, discount_rub, coupon_id, applied_coupon_code = await resolve_percent_coupon_pricing(
|
||||
session=session,
|
||||
billing_user_id=int(billing_user_id),
|
||||
base_price_rub=int(max(0, extra_price_rub)),
|
||||
coupon_code=body.coupon_code,
|
||||
)
|
||||
balance = float(await get_balance(session, int(billing_user_id)))
|
||||
required_amount = int(max(0, ceil(float(final_extra_price_rub) - balance)))
|
||||
if extra_price_rub <= 0:
|
||||
return AccountKeyApplyAddonsResponse(
|
||||
ok=True,
|
||||
message="Доплата не требуется",
|
||||
client_id=str(getattr(db_key, "client_id", "") or ""),
|
||||
tariff_id=int(tariff_id),
|
||||
total_price_rub=int(total_price_after_purchase),
|
||||
extra_price_rub=0,
|
||||
discount_rub=0,
|
||||
final_price_rub=0,
|
||||
applied_coupon_code=None,
|
||||
charged_rub=0,
|
||||
balance_rub=balance,
|
||||
)
|
||||
expiry_time = int(getattr(db_key, "expiry_time", 0) or 0)
|
||||
email = str(getattr(db_key, "email", "") or "")
|
||||
server_id = str(getattr(db_key, "server_id", "") or "")
|
||||
if not email or not server_id:
|
||||
raise HTTPException(status_code=400, detail="Некорректные данные подписки")
|
||||
if required_amount > 0:
|
||||
provider_id = str(body.provider_id or _resolve_default_web_payment_provider() or "").strip().upper()
|
||||
if not provider_id:
|
||||
raise HTTPException(status_code=503, detail="Нет доступных провайдеров оплаты")
|
||||
base_url = _resolve_public_base_url(request)
|
||||
success_url = validate_redirect_url(str(body.success_url or ""), f"{base_url}/payment-success")
|
||||
failure_url = validate_redirect_url(str(body.failure_url or ""), f"{base_url}/payment-failure")
|
||||
payment_request = PaymentLinkRequest(
|
||||
legacy_user_ref=int(billing_user_id),
|
||||
amount=required_amount,
|
||||
currency="RUB",
|
||||
provider_id=provider_id,
|
||||
success_url=success_url,
|
||||
failure_url=failure_url,
|
||||
metadata={
|
||||
"payment_flow": "key_addons",
|
||||
"tariff_id": int(tariff_id),
|
||||
"email": email,
|
||||
"selected_device_limit": int(selected_device) if has_device_option and include_device_effective and selected_device is not None else None,
|
||||
"selected_traffic_gb": int(selected_traffic) if has_traffic_option and include_traffic_effective and selected_traffic is not None else None,
|
||||
"current_device_limit": int(current_device_limit) if current_device_limit is not None else None,
|
||||
"current_traffic_gb": int(current_traffic_gb) if current_traffic_gb is not None else None,
|
||||
"original_price": int(base_price_for_current),
|
||||
"base_price_rub": int(max(0, extra_price_rub)),
|
||||
"discount_rub": int(discount_rub),
|
||||
"applied_coupon_code": applied_coupon_code,
|
||||
"coupon_id": int(coupon_id) if coupon_id is not None else None,
|
||||
},
|
||||
)
|
||||
payment_result = await create_payment_link(session, payment_request)
|
||||
if not payment_result.success or not payment_result.payment_url or not payment_result.payment_id:
|
||||
raise HTTPException(status_code=400, detail=payment_result.error or "Не удалось создать ссылку оплаты")
|
||||
await create_temporary_data(
|
||||
session,
|
||||
int(billing_user_id),
|
||||
"waiting_for_addons_payment",
|
||||
{
|
||||
"tariff_id": int(tariff_id),
|
||||
"email": email,
|
||||
"required_amount": int(required_amount),
|
||||
"selected_device_limit": int(selected_device) if has_device_option and include_device_effective and selected_device is not None else None,
|
||||
"selected_traffic_gb": int(selected_traffic) if has_traffic_option and include_traffic_effective and selected_traffic is not None else None,
|
||||
"current_device_limit": int(current_device_limit) if current_device_limit is not None else None,
|
||||
"current_traffic_gb": int(current_traffic_gb) if current_traffic_gb is not None else None,
|
||||
"original_price": int(base_price_for_current),
|
||||
"base_price_rub": int(max(0, extra_price_rub)),
|
||||
"discount_rub": int(discount_rub),
|
||||
"applied_coupon_code": applied_coupon_code,
|
||||
"coupon_id": int(coupon_id) if coupon_id is not None else None,
|
||||
},
|
||||
)
|
||||
return AccountKeyApplyAddonsResponse(
|
||||
ok=True,
|
||||
message="Требуется оплата для применения доп. опций",
|
||||
client_id=str(getattr(db_key, "client_id", "") or ""),
|
||||
tariff_id=int(tariff_id),
|
||||
total_price_rub=int(total_price_after_purchase),
|
||||
extra_price_rub=int(extra_price_rub),
|
||||
discount_rub=int(discount_rub),
|
||||
final_price_rub=int(final_extra_price_rub),
|
||||
applied_coupon_code=applied_coupon_code,
|
||||
charged_rub=0,
|
||||
balance_rub=balance,
|
||||
payment_required=True,
|
||||
required_amount_rub=required_amount,
|
||||
payment_id=payment_result.payment_id,
|
||||
payment_url=payment_result.payment_url,
|
||||
)
|
||||
target_subgroup = tariff.get("subgroup_title")
|
||||
current_subgroup = None
|
||||
current_tariff_id = key_details.get("tariff_id")
|
||||
if current_tariff_id:
|
||||
current_tariff = await get_tariff_by_id(session, int(current_tariff_id))
|
||||
if current_tariff:
|
||||
current_subgroup = current_tariff.get("subgroup_title")
|
||||
if pack_mode:
|
||||
device_limit_effective_current, traffic_limit_bytes_effective_current = await get_effective_limits_for_key(
|
||||
session=session,
|
||||
tariff_id=int(tariff_id),
|
||||
selected_device_limit=int(current_device_limit) if current_device_limit is not None else None,
|
||||
selected_traffic_gb=int(current_traffic_gb) if current_traffic_gb is not None else None,
|
||||
)
|
||||
traffic_limit_gb_effective_current = (
|
||||
int(traffic_limit_bytes_effective_current / GB) if traffic_limit_bytes_effective_current else 0
|
||||
)
|
||||
new_device_limit_effective = device_limit_effective_current
|
||||
new_traffic_limit_gb_effective = traffic_limit_gb_effective_current
|
||||
if has_device_option and include_device_effective and selected_device is not None:
|
||||
pack_devices_val = int(selected_device)
|
||||
if pack_devices_val <= 0 or (
|
||||
new_device_limit_effective is not None and int(new_device_limit_effective) <= 0
|
||||
):
|
||||
new_device_limit_effective = 0
|
||||
else:
|
||||
if new_device_limit_effective is None:
|
||||
new_device_limit_effective = pack_devices_val
|
||||
else:
|
||||
new_device_limit_effective = int(new_device_limit_effective) + pack_devices_val
|
||||
if has_traffic_option and include_traffic_effective and selected_traffic is not None:
|
||||
pack_traffic_val = int(selected_traffic)
|
||||
if pack_traffic_val <= 0 or int(new_traffic_limit_gb_effective) <= 0:
|
||||
new_traffic_limit_gb_effective = 0
|
||||
else:
|
||||
new_traffic_limit_gb_effective = int(new_traffic_limit_gb_effective) + pack_traffic_val
|
||||
await renew_key_in_cluster(
|
||||
cluster_id=server_id,
|
||||
email=email,
|
||||
client_id=str(getattr(db_key, "client_id", "") or ""),
|
||||
new_expiry_time=expiry_time,
|
||||
total_gb=int(new_traffic_limit_gb_effective),
|
||||
session=session,
|
||||
hwid_device_limit=int(new_device_limit_effective) if new_device_limit_effective is not None else 0,
|
||||
reset_traffic=False,
|
||||
target_subgroup=target_subgroup,
|
||||
old_subgroup=current_subgroup,
|
||||
plan=int(tariff_id),
|
||||
)
|
||||
await save_key_config_with_mode(
|
||||
session=session,
|
||||
email=email,
|
||||
selected_devices=int(new_device_limit_effective) if new_device_limit_effective is not None else None,
|
||||
selected_traffic_gb=int(new_traffic_limit_gb_effective) if new_traffic_limit_gb_effective is not None else None,
|
||||
total_price=int(total_price_after_purchase),
|
||||
has_device_choice=bool(has_device_option and include_device_effective),
|
||||
has_traffic_choice=bool(has_traffic_option and include_traffic_effective),
|
||||
config_mode="pack",
|
||||
)
|
||||
else:
|
||||
selected_device_for_effective = int(selected_device) if has_device_option and include_device_effective and selected_device is not None else None
|
||||
selected_traffic_for_effective = int(selected_traffic) if has_traffic_option and include_traffic_effective and selected_traffic is not None else 0
|
||||
device_limit_effective_new, traffic_limit_bytes_effective_new = await get_effective_limits_for_key(
|
||||
session=session,
|
||||
tariff_id=int(tariff_id),
|
||||
selected_device_limit=selected_device_for_effective,
|
||||
selected_traffic_gb=selected_traffic_for_effective,
|
||||
)
|
||||
traffic_limit_gb_effective = int(traffic_limit_bytes_effective_new / GB) if traffic_limit_bytes_effective_new else 0
|
||||
await renew_key_in_cluster(
|
||||
cluster_id=server_id,
|
||||
email=email,
|
||||
client_id=str(getattr(db_key, "client_id", "") or ""),
|
||||
new_expiry_time=expiry_time,
|
||||
total_gb=int(traffic_limit_gb_effective),
|
||||
session=session,
|
||||
hwid_device_limit=int(device_limit_effective_new) if device_limit_effective_new is not None else 0,
|
||||
reset_traffic=False,
|
||||
target_subgroup=target_subgroup,
|
||||
old_subgroup=current_subgroup,
|
||||
plan=int(tariff_id),
|
||||
)
|
||||
await save_key_config_with_mode(
|
||||
session=session,
|
||||
email=email,
|
||||
selected_devices=int(selected_device) if has_device_option and include_device_effective and selected_device is not None else None,
|
||||
selected_traffic_gb=int(selected_traffic) if has_traffic_option and include_traffic_effective and selected_traffic is not None else None,
|
||||
total_price=int(total_price_after_purchase),
|
||||
has_device_choice=bool(has_device_option and include_device_effective),
|
||||
has_traffic_choice=bool(has_traffic_option and include_traffic_effective),
|
||||
config_mode="addon",
|
||||
)
|
||||
await update_balance(session, int(billing_user_id), -int(final_extra_price_rub))
|
||||
if coupon_id is not None:
|
||||
await mark_coupon_used(session, int(coupon_id), int(billing_user_id))
|
||||
await session.commit()
|
||||
return AccountKeyApplyAddonsResponse(
|
||||
ok=True,
|
||||
message="Доп. опции применены",
|
||||
client_id=str(getattr(db_key, "client_id", "") or ""),
|
||||
tariff_id=int(tariff_id),
|
||||
total_price_rub=int(total_price_after_purchase),
|
||||
extra_price_rub=int(extra_price_rub),
|
||||
discount_rub=int(discount_rub),
|
||||
final_price_rub=int(final_extra_price_rub),
|
||||
applied_coupon_code=applied_coupon_code,
|
||||
charged_rub=int(final_extra_price_rub),
|
||||
balance_rub=float(await get_balance(session, int(billing_user_id))),
|
||||
)
|
||||
@@ -0,0 +1,249 @@
|
||||
"""User-facing key endpoints (/api/keys/*).
|
||||
|
||||
Регистрирует эндпоинты на ``user_router`` из ``_common``. Импорт этого модуля
|
||||
из ``__init__.py`` запускает регистрацию декораторов.
|
||||
"""
|
||||
|
||||
from .._common import * # noqa: F401,F403 — подтягиваем все имена для endpoints
|
||||
from .._common import (
|
||||
_key_actions_config,
|
||||
_resolve_available_location_servers,
|
||||
_resolve_billing_user_id,
|
||||
_resolve_default_web_payment_provider,
|
||||
_resolve_public_base_url,
|
||||
_normalize_expiry_ms,
|
||||
router,
|
||||
user_router,
|
||||
)
|
||||
|
||||
|
||||
@user_router.get("", response_model=list[AccountKeyResponse])
|
||||
async def user_keys(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
billing_user_id = await _resolve_billing_user_id(request, identity, session)
|
||||
keys = await get_keys(session, billing_user_id)
|
||||
result: list[AccountKeyResponse] = []
|
||||
for key in keys:
|
||||
key_actions = AccountKeyActionsAvailability()
|
||||
try:
|
||||
key_ref = str(getattr(key, "client_id", "") or getattr(key, "email", "") or "")
|
||||
_, markup, _ = await build_key_view_payload(session, int(billing_user_id), key_ref)
|
||||
key_actions = _extract_key_actions_from_markup(markup)
|
||||
except Exception:
|
||||
key_actions = AccountKeyActionsAvailability()
|
||||
result.append(
|
||||
AccountKeyResponse(
|
||||
email=str(getattr(key, "email", "") or ""),
|
||||
alias=getattr(key, "alias", None),
|
||||
client_id=str(getattr(key, "client_id", "") or ""),
|
||||
tariff_id=getattr(key, "tariff_id", None),
|
||||
server_id=str(getattr(key, "server_id", "") or ""),
|
||||
created_at=int(getattr(key, "created_at", 0) or 0),
|
||||
expiry_time=int(getattr(key, "expiry_time", 0) or 0),
|
||||
key=getattr(key, "key", None),
|
||||
remnawave_link=getattr(key, "remnawave_link", None),
|
||||
is_frozen=bool(getattr(key, "is_frozen", False)),
|
||||
actions=key_actions,
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@user_router.get("/actions-config", response_model=AccountKeyActionsConfigResponse)
|
||||
async def user_keys_actions_config(
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
_ = identity
|
||||
return _key_actions_config()
|
||||
|
||||
|
||||
@user_router.get("/{client_id}/details", response_model=AccountKeyDetailsResponse)
|
||||
async def user_key_details(
|
||||
client_id: str,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
billing_user_id = await _resolve_billing_user_id(request, identity, session)
|
||||
db_key = (
|
||||
await session.execute(
|
||||
select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if db_key is None:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
key_details = await get_key_details(session, str(getattr(db_key, "email", "") or ""))
|
||||
if not key_details:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
tariff_name = ""
|
||||
subgroup_title = ""
|
||||
traffic_limit_gb = 0
|
||||
device_limit = 0
|
||||
is_tariff_configurable = False
|
||||
addons_devices_enabled = False
|
||||
addons_traffic_enabled = False
|
||||
(
|
||||
tariff_name,
|
||||
subgroup_title,
|
||||
traffic_limit_gb,
|
||||
device_limit,
|
||||
_,
|
||||
is_tariff_configurable,
|
||||
addons_devices_enabled,
|
||||
addons_traffic_enabled,
|
||||
) = await get_key_tariff_addons_state(
|
||||
session=session,
|
||||
key_record=key_details,
|
||||
db_key=db_key,
|
||||
)
|
||||
connected_devices = 0
|
||||
used_traffic_gb = None
|
||||
try:
|
||||
profile = await get_remnawave_profile(
|
||||
session,
|
||||
str(getattr(db_key, "server_id", "") or ""),
|
||||
client_id,
|
||||
fallback_any=True,
|
||||
)
|
||||
if profile:
|
||||
connected_devices = int(profile.get("hwid_count") or 0)
|
||||
used_raw = profile.get("used_gb")
|
||||
used_traffic_gb = float(used_raw) if used_raw is not None else None
|
||||
traffic_limit_bytes_actual = profile.get("traffic_limit_bytes")
|
||||
if traffic_limit_bytes_actual is not None:
|
||||
try:
|
||||
traffic_limit_bytes_actual = int(traffic_limit_bytes_actual)
|
||||
traffic_limit_gb = int(traffic_limit_bytes_actual / GB) if traffic_limit_bytes_actual > 0 else 0
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
except Exception:
|
||||
connected_devices = 0
|
||||
used_traffic_gb = None
|
||||
return AccountKeyDetailsResponse(
|
||||
client_id=str(getattr(db_key, "client_id", "") or ""),
|
||||
email=str(getattr(db_key, "email", "") or ""),
|
||||
alias=getattr(db_key, "alias", None),
|
||||
expiry_time=int(getattr(db_key, "expiry_time", 0) or 0),
|
||||
is_frozen=bool(getattr(db_key, "is_frozen", False)),
|
||||
tariff_name=str(tariff_name or ""),
|
||||
subgroup_title=str(subgroup_title or ""),
|
||||
traffic_limit_gb=int(traffic_limit_gb or 0),
|
||||
used_traffic_gb=used_traffic_gb,
|
||||
device_limit=int(device_limit or 0),
|
||||
connected_devices=int(connected_devices or 0),
|
||||
is_tariff_configurable=bool(is_tariff_configurable),
|
||||
addons_devices_enabled=bool(addons_devices_enabled),
|
||||
addons_traffic_enabled=bool(addons_traffic_enabled),
|
||||
)
|
||||
|
||||
|
||||
@user_router.get("/{client_id}/qr", response_model=AccountKeyQrResponse)
|
||||
async def user_key_qr(
|
||||
client_id: str,
|
||||
request: Request,
|
||||
force_web: bool = Query(False),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
actions = _key_actions_config()
|
||||
if not force_web and not actions.qr_enabled:
|
||||
raise HTTPException(status_code=403, detail="QR для подписок отключен в настройках")
|
||||
billing_user_id = await _resolve_billing_user_id(request, identity, session)
|
||||
db_key = (
|
||||
await session.execute(
|
||||
select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if db_key is None:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
qr_data = str(getattr(db_key, "key", "") or "").strip() or str(getattr(db_key, "remnawave_link", "") or "").strip()
|
||||
if not qr_data:
|
||||
raise HTTPException(status_code=400, detail="Ссылка для подключения отсутствует")
|
||||
qr = qrcode.QRCode(version=1, box_size=10, border=4)
|
||||
qr.add_data(qr_data)
|
||||
qr.make(fit=True)
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
buffer = BytesIO()
|
||||
img.save(buffer, format="PNG")
|
||||
image_data = b64encode(buffer.getvalue()).decode("ascii")
|
||||
return AccountKeyQrResponse(
|
||||
ok=True,
|
||||
message="QR-код готов",
|
||||
link=qr_data,
|
||||
image_data_url=f"data:image/png;base64,{image_data}",
|
||||
)
|
||||
|
||||
|
||||
@user_router.patch("/{client_id}/alias", response_model=AccountKeyResponse)
|
||||
async def user_key_update_alias(
|
||||
client_id: str,
|
||||
body: AccountKeyAliasUpdateRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
alias = str(body.alias or "").strip()
|
||||
if not alias:
|
||||
raise HTTPException(status_code=400, detail="Укажите alias")
|
||||
if len(alias) > 10:
|
||||
raise HTTPException(status_code=400, detail="Alias должен быть не длиннее 10 символов")
|
||||
if not re.match(r"^[a-zA-Zа-яА-ЯёЁ0-9@._-]+$", alias):
|
||||
raise HTTPException(status_code=400, detail="Alias содержит недопустимые символы")
|
||||
billing_user_id = await _resolve_billing_user_id(request, identity, session)
|
||||
db_key = (
|
||||
await session.execute(
|
||||
select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if db_key is None:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
db_key.alias = alias
|
||||
await session.commit()
|
||||
return AccountKeyResponse(
|
||||
email=str(getattr(db_key, "email", "") or ""),
|
||||
alias=getattr(db_key, "alias", None),
|
||||
client_id=str(getattr(db_key, "client_id", "") or ""),
|
||||
tariff_id=getattr(db_key, "tariff_id", None),
|
||||
server_id=str(getattr(db_key, "server_id", "") or ""),
|
||||
created_at=int(getattr(db_key, "created_at", 0) or 0),
|
||||
expiry_time=int(getattr(db_key, "expiry_time", 0) or 0),
|
||||
key=getattr(db_key, "key", None),
|
||||
remnawave_link=getattr(db_key, "remnawave_link", None),
|
||||
is_frozen=bool(getattr(db_key, "is_frozen", False)),
|
||||
)
|
||||
|
||||
|
||||
@user_router.delete("/{client_id}", response_model=AccountKeyActionResponse)
|
||||
async def user_key_delete(
|
||||
client_id: str,
|
||||
request: Request,
|
||||
force_web: bool = Query(False),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
actions = _key_actions_config()
|
||||
if not force_web and not actions.delete_enabled:
|
||||
raise HTTPException(status_code=403, detail="Удаление подписки отключено в настройках")
|
||||
billing_user_id = await _resolve_billing_user_id(request, identity, session)
|
||||
db_key = (
|
||||
await session.execute(
|
||||
select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if db_key is None:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
cluster_id = str(getattr(db_key, "server_id", "") or "")
|
||||
email = str(getattr(db_key, "email", "") or "")
|
||||
if cluster_id and email:
|
||||
await delete_key_from_cluster(
|
||||
cluster_id=cluster_id,
|
||||
email=email,
|
||||
client_id=client_id,
|
||||
session=session,
|
||||
)
|
||||
await session.delete(db_key)
|
||||
await session.commit()
|
||||
return AccountKeyActionResponse(ok=True, message="Подписка удалена")
|
||||
@@ -0,0 +1,75 @@
|
||||
"""User-facing key endpoints (/api/keys/*).
|
||||
|
||||
Регистрирует эндпоинты на ``user_router`` из ``_common``. Импорт этого модуля
|
||||
из ``__init__.py`` запускает регистрацию декораторов.
|
||||
"""
|
||||
|
||||
from .._common import * # noqa: F401,F403 — подтягиваем все имена для endpoints
|
||||
from .._common import (
|
||||
_key_actions_config,
|
||||
_resolve_available_location_servers,
|
||||
_resolve_billing_user_id,
|
||||
_resolve_default_web_payment_provider,
|
||||
_resolve_public_base_url,
|
||||
_normalize_expiry_ms,
|
||||
router,
|
||||
user_router,
|
||||
)
|
||||
|
||||
|
||||
@user_router.post("/{client_id}/reset-hwid", response_model=AccountKeyResetHwidResponse)
|
||||
async def user_key_reset_hwid(
|
||||
client_id: str,
|
||||
request: Request,
|
||||
force_web: bool = Query(False),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
actions = _key_actions_config()
|
||||
if not force_web and not actions.hwid_reset_enabled:
|
||||
raise HTTPException(status_code=403, detail="Сброс устройств отключен в настройках")
|
||||
billing_user_id = await _resolve_billing_user_id(request, identity, session)
|
||||
db_key = (
|
||||
await session.execute(
|
||||
select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if db_key is None:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
server_id = str(getattr(db_key, "server_id", "") or "")
|
||||
if not server_id:
|
||||
raise HTTPException(status_code=400, detail="У подписки не указан сервер")
|
||||
|
||||
async def _reset_devices(api):
|
||||
devices = await api.get_user_hwid_devices(client_id)
|
||||
if not devices:
|
||||
return 0, 0
|
||||
reset_local = 0
|
||||
for device in devices:
|
||||
hwid = device.get("hwid")
|
||||
if hwid and await api.delete_user_hwid_device(client_id, hwid):
|
||||
reset_local += 1
|
||||
return len(devices), reset_local
|
||||
|
||||
reset_result = await with_remnawave_api(
|
||||
session,
|
||||
server_id,
|
||||
_reset_devices,
|
||||
fallback_any=True,
|
||||
timeout_sec=12.0,
|
||||
)
|
||||
if reset_result is None:
|
||||
raise HTTPException(status_code=502, detail="Не удалось выполнить сброс устройств")
|
||||
total_devices, reset_devices = reset_result
|
||||
await invalidate_remnawave_profile(
|
||||
session,
|
||||
server_id,
|
||||
str(client_id),
|
||||
fallback_any=True,
|
||||
)
|
||||
return AccountKeyResetHwidResponse(
|
||||
ok=True,
|
||||
message="Устройства сброшены" if total_devices > 0 else "Устройства не были привязаны",
|
||||
total_devices=int(total_devices),
|
||||
reset_devices=int(reset_devices),
|
||||
)
|
||||
@@ -0,0 +1,230 @@
|
||||
"""User-facing key endpoints (/api/keys/*).
|
||||
|
||||
Регистрирует эндпоинты на ``user_router`` из ``_common``. Импорт этого модуля
|
||||
из ``__init__.py`` запускает регистрацию декораторов.
|
||||
"""
|
||||
|
||||
from .._common import * # noqa: F401,F403 — подтягиваем все имена для endpoints
|
||||
from .._common import (
|
||||
_key_actions_config,
|
||||
_resolve_available_location_servers,
|
||||
_resolve_billing_user_id,
|
||||
_resolve_default_web_payment_provider,
|
||||
_resolve_public_base_url,
|
||||
_normalize_expiry_ms,
|
||||
router,
|
||||
user_router,
|
||||
)
|
||||
|
||||
|
||||
@user_router.get("/{client_id}/locations", response_model=AccountKeyLocationsResponse)
|
||||
async def user_key_locations(
|
||||
client_id: str,
|
||||
request: Request,
|
||||
force_web: bool = Query(False),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
actions = _key_actions_config()
|
||||
if not force_web and not actions.country_change_enabled:
|
||||
raise HTTPException(status_code=403, detail="Смена локации отключена в настройках")
|
||||
billing_user_id = await _resolve_billing_user_id(request, identity, session)
|
||||
db_key = (
|
||||
await session.execute(
|
||||
select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if db_key is None:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
names = await _resolve_available_location_servers(session, db_key)
|
||||
return AccountKeyLocationsResponse(
|
||||
client_id=str(getattr(db_key, "client_id", "") or ""),
|
||||
current_server=str(getattr(db_key, "server_id", "") or ""),
|
||||
locations=[AccountKeyLocationOptionResponse(server_name=name) for name in names],
|
||||
)
|
||||
|
||||
|
||||
@user_router.post("/{client_id}/change-location", response_model=AccountKeyChangeLocationResponse)
|
||||
async def user_key_change_location(
|
||||
client_id: str,
|
||||
body: AccountKeyChangeLocationRequest,
|
||||
request: Request,
|
||||
force_web: bool = Query(False),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
actions = _key_actions_config()
|
||||
if not force_web and not actions.country_change_enabled:
|
||||
raise HTTPException(status_code=403, detail="Смена локации отключена в настройках")
|
||||
target_server = str(body.server_name or "").strip()
|
||||
if not target_server:
|
||||
raise HTTPException(status_code=400, detail="Укажите целевую локацию")
|
||||
billing_user_id = await _resolve_billing_user_id(request, identity, session)
|
||||
db_key = (
|
||||
await session.execute(
|
||||
select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if db_key is None:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
current_server = str(getattr(db_key, "server_id", "") or "")
|
||||
if not current_server:
|
||||
raise HTTPException(status_code=400, detail="У подписки не указан текущий сервер")
|
||||
if current_server == target_server:
|
||||
raise HTTPException(status_code=400, detail="Подписка уже в этой локации")
|
||||
available_names = await _resolve_available_location_servers(session, db_key)
|
||||
if target_server not in available_names:
|
||||
raise HTTPException(status_code=400, detail="Выбранная локация недоступна")
|
||||
email = str(getattr(db_key, "email", "") or "")
|
||||
if not email:
|
||||
raise HTTPException(status_code=400, detail="У подписки отсутствует email")
|
||||
key_details = await get_key_details(session, email)
|
||||
if not key_details:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
old_server_info = (
|
||||
await session.execute(select(Server).where(Server.server_name == current_server).limit(1))
|
||||
).scalar_one_or_none()
|
||||
if old_server_info:
|
||||
old_panel_type = str(getattr(old_server_info, "panel_type", "") or "").lower()
|
||||
try:
|
||||
if old_panel_type == "3x-ui":
|
||||
xui = await get_xui_instance(str(getattr(old_server_info, "api_url", "") or ""))
|
||||
await delete_client(
|
||||
xui,
|
||||
int(getattr(old_server_info, "inbound_id", 0) or 0),
|
||||
email,
|
||||
str(getattr(db_key, "client_id", "") or ""),
|
||||
)
|
||||
elif old_panel_type == "remnawave":
|
||||
remna_del = RemnawaveAPI(str(getattr(old_server_info, "api_url", "") or ""))
|
||||
if await remna_del.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
|
||||
await remna_del.delete_user(str(getattr(db_key, "client_id", "") or ""))
|
||||
except Exception:
|
||||
pass
|
||||
target_server_info = (
|
||||
await session.execute(select(Server).where(Server.server_name == target_server).limit(1))
|
||||
).scalar_one_or_none()
|
||||
if target_server_info is None:
|
||||
raise HTTPException(status_code=404, detail="Целевая локация не найдена")
|
||||
tariff_id = getattr(db_key, "tariff_id", None)
|
||||
tariff = await get_tariff_by_id(session, int(tariff_id)) if tariff_id else None
|
||||
need_vless_key = bool(tariff.get("vless")) if tariff else False
|
||||
external_squad_uuid = (tariff.get("external_squad") if tariff else None) or None
|
||||
selected_traffic_gb = getattr(db_key, "selected_traffic_limit", None)
|
||||
selected_device_limit = getattr(db_key, "selected_device_limit", None)
|
||||
if selected_traffic_gb is not None:
|
||||
traffic_limit_bytes = int(selected_traffic_gb) * GB
|
||||
else:
|
||||
raw_traffic_limit = int(tariff.get("traffic_limit") or 0) if tariff else 0
|
||||
traffic_limit_bytes = raw_traffic_limit * GB if raw_traffic_limit > 0 else 0
|
||||
if selected_device_limit is not None:
|
||||
device_limit = int(selected_device_limit)
|
||||
else:
|
||||
device_limit = int(tariff.get("device_limit") or 0) if tariff else 0
|
||||
key_client_id = str(getattr(db_key, "client_id", "") or "")
|
||||
expiry_timestamp = int(getattr(db_key, "expiry_time", 0) or 0)
|
||||
target_cluster_info = await check_server_name_by_cluster(session, target_server)
|
||||
target_cluster_name = str((target_cluster_info or {}).get("cluster_name") or "")
|
||||
full_remnawave_cluster = (
|
||||
await is_full_remnawave_cluster(target_cluster_name, session) if target_cluster_name else False
|
||||
)
|
||||
panel_type = str(getattr(target_server_info, "panel_type", "") or "").lower()
|
||||
remnawave_link = None
|
||||
if panel_type == "remnawave" or full_remnawave_cluster:
|
||||
remna = RemnawaveAPI(str(getattr(target_server_info, "api_url", "") or ""))
|
||||
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
|
||||
raise HTTPException(status_code=502, detail="Не удалось авторизоваться в Remnawave")
|
||||
expire_at = datetime.utcfromtimestamp(expiry_timestamp / 1000).isoformat() + "Z"
|
||||
user_data: dict[str, Any] = {
|
||||
"username": email,
|
||||
"trafficLimitStrategy": "NO_RESET",
|
||||
"expireAt": expire_at,
|
||||
"telegramId": int(key_details.get("tg_id") or 0),
|
||||
"activeInternalSquads": [getattr(target_server_info, "inbound_id", None)],
|
||||
"uuid": key_client_id,
|
||||
}
|
||||
if traffic_limit_bytes > 0:
|
||||
user_data["trafficLimitBytes"] = traffic_limit_bytes
|
||||
if device_limit > 0:
|
||||
user_data["hwidDeviceLimit"] = device_limit
|
||||
if external_squad_uuid:
|
||||
user_data["externalSquadUuid"] = external_squad_uuid
|
||||
result = await remna.create_user(user_data)
|
||||
if not result:
|
||||
raise HTTPException(status_code=502, detail="Не удалось создать подписку в новой локации")
|
||||
key_client_id = str(result.get("uuid") or result.get("id") or key_client_id)
|
||||
if need_vless_key:
|
||||
try:
|
||||
remnawave_link = await get_vless_link_for_remnawave_by_username(remna, email, email)
|
||||
except Exception:
|
||||
remnawave_link = None
|
||||
if not remnawave_link:
|
||||
try:
|
||||
sub = await remna.get_subscription_by_username(email)
|
||||
except Exception:
|
||||
sub = None
|
||||
if sub:
|
||||
links = sub.get("links") or []
|
||||
remnawave_link = (
|
||||
next(
|
||||
(link for link in links if isinstance(link, str) and link.lower().startswith("vless://")),
|
||||
None,
|
||||
)
|
||||
if need_vless_key
|
||||
else None
|
||||
)
|
||||
if not remnawave_link:
|
||||
remnawave_link = sub.get("subscriptionUrl")
|
||||
if panel_type == "3x-ui":
|
||||
await create_client_on_server(
|
||||
{
|
||||
"api_url": str(getattr(target_server_info, "api_url", "") or ""),
|
||||
"inbound_id": getattr(target_server_info, "inbound_id", None),
|
||||
"server_name": str(getattr(target_server_info, "server_name", "") or ""),
|
||||
"panel_type": str(getattr(target_server_info, "panel_type", "") or ""),
|
||||
},
|
||||
int(key_details.get("tg_id") or 0),
|
||||
key_client_id,
|
||||
email,
|
||||
expiry_timestamp,
|
||||
asyncio.Semaphore(1),
|
||||
plan=int(tariff_id) if tariff_id else None,
|
||||
session=session,
|
||||
is_trial=False,
|
||||
total_traffic_limit_bytes=traffic_limit_bytes,
|
||||
device_limit_value=device_limit,
|
||||
)
|
||||
subgroup_code = tariff.get("subgroup_title") if tariff and tariff.get("subgroup_title") else None
|
||||
public_link = await make_aggregated_link(
|
||||
session=session,
|
||||
cluster_all=[
|
||||
{
|
||||
"server_name": str(getattr(target_server_info, "server_name", "") or ""),
|
||||
"api_url": str(getattr(target_server_info, "api_url", "") or ""),
|
||||
"panel_type": str(getattr(target_server_info, "panel_type", "") or ""),
|
||||
"inbound_id": getattr(target_server_info, "inbound_id", None),
|
||||
"enabled": True,
|
||||
"max_keys": getattr(target_server_info, "max_keys", None),
|
||||
}
|
||||
],
|
||||
cluster_id=target_cluster_name or target_server,
|
||||
email=email,
|
||||
client_id=key_client_id,
|
||||
tg_id=int(key_details.get("tg_id") or 0),
|
||||
subgroup_code=subgroup_code,
|
||||
remna_link_override=remnawave_link,
|
||||
plan=int(tariff_id) if tariff_id else None,
|
||||
)
|
||||
db_key.server_id = target_server
|
||||
db_key.client_id = key_client_id
|
||||
db_key.key = public_link if isinstance(public_link, str) and public_link.strip() else None
|
||||
db_key.remnawave_link = remnawave_link
|
||||
await session.commit()
|
||||
return AccountKeyChangeLocationResponse(
|
||||
ok=True,
|
||||
message="Локация успешно изменена",
|
||||
client_id=str(getattr(db_key, "client_id", "") or ""),
|
||||
server_id=str(getattr(db_key, "server_id", "") or ""),
|
||||
link=str(getattr(db_key, "key", "") or ""),
|
||||
remnawave_link=getattr(db_key, "remnawave_link", None),
|
||||
)
|
||||
@@ -0,0 +1,191 @@
|
||||
"""User-facing key endpoints (/api/keys/*).
|
||||
|
||||
Регистрирует эндпоинты на ``user_router`` из ``_common``. Импорт этого модуля
|
||||
из ``__init__.py`` запускает регистрацию декораторов.
|
||||
"""
|
||||
|
||||
from .._common import * # noqa: F401,F403 — подтягиваем все имена для endpoints
|
||||
from .._common import (
|
||||
_key_actions_config,
|
||||
_resolve_available_location_servers,
|
||||
_resolve_billing_user_id,
|
||||
_resolve_default_web_payment_provider,
|
||||
_resolve_public_base_url,
|
||||
_normalize_expiry_ms,
|
||||
router,
|
||||
user_router,
|
||||
)
|
||||
|
||||
|
||||
@user_router.post("/{client_id}/renew", response_model=AccountKeyRenewResponse)
|
||||
async def user_key_renew(
|
||||
client_id: str,
|
||||
body: AccountKeyRenewRequest,
|
||||
request: Request,
|
||||
force_web: bool = Query(False),
|
||||
preview: bool = Query(False),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
from services.errors import ServiceError
|
||||
from services.keys import (
|
||||
calculate_renewal_pricing,
|
||||
execute_renewal,
|
||||
normalize_expiry_ms as _svc_normalize_expiry,
|
||||
)
|
||||
|
||||
actions = _key_actions_config()
|
||||
if not force_web and not actions.renew_enabled:
|
||||
raise HTTPException(status_code=403, detail="Продление подписки отключено в настройках")
|
||||
billing_user_id = await _resolve_billing_user_id(request, identity, session)
|
||||
db_key = (
|
||||
await session.execute(
|
||||
select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if db_key is None:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
if bool(getattr(db_key, "is_frozen", False)):
|
||||
raise HTTPException(status_code=400, detail="Продление для замороженной подписки недоступно")
|
||||
tariff_id = getattr(db_key, "tariff_id", None)
|
||||
if not tariff_id:
|
||||
raise HTTPException(status_code=400, detail="Для подписки не назначен тариф")
|
||||
key_email = str(getattr(db_key, "email", "") or "")
|
||||
key_server_id = str(getattr(db_key, "server_id", "") or "")
|
||||
|
||||
try:
|
||||
pricing = await calculate_renewal_pricing(
|
||||
session=session,
|
||||
billing_user_id=int(billing_user_id),
|
||||
key_email=key_email,
|
||||
tariff_id=int(tariff_id),
|
||||
coupon_code=body.coupon_code,
|
||||
)
|
||||
except ServiceError as e:
|
||||
raise HTTPException(status_code=400, detail=e.message)
|
||||
|
||||
if preview:
|
||||
return AccountKeyRenewResponse(
|
||||
ok=True,
|
||||
message="Расчет обновлен",
|
||||
client_id=str(client_id),
|
||||
tariff_id=int(tariff_id),
|
||||
charged_rub=0,
|
||||
balance_rub=pricing.balance,
|
||||
base_price_rub=pricing.base_price_rub,
|
||||
discount_rub=pricing.discount_rub,
|
||||
final_price_rub=pricing.final_price_rub,
|
||||
applied_coupon_code=pricing.applied_coupon_code,
|
||||
payment_required=pricing.payment_required,
|
||||
required_amount_rub=pricing.required_amount,
|
||||
payment_id=None,
|
||||
payment_url=None,
|
||||
)
|
||||
if pricing.payment_required:
|
||||
provider_id = str(body.provider_id or _resolve_default_web_payment_provider() or "").strip().upper()
|
||||
if not provider_id:
|
||||
raise HTTPException(status_code=503, detail="Нет доступных провайдеров оплаты")
|
||||
base_url = _resolve_public_base_url(request)
|
||||
success_url = validate_redirect_url(str(body.success_url or ""), f"{base_url}/payment-success")
|
||||
failure_url = validate_redirect_url(str(body.failure_url or ""), f"{base_url}/payment-failure")
|
||||
payment_request = PaymentLinkRequest(
|
||||
legacy_user_ref=int(billing_user_id),
|
||||
amount=pricing.required_amount,
|
||||
currency="RUB",
|
||||
provider_id=provider_id,
|
||||
success_url=success_url,
|
||||
failure_url=failure_url,
|
||||
metadata={
|
||||
"payment_flow": "key_renewal",
|
||||
"tariff_id": int(tariff_id),
|
||||
"client_id": str(client_id),
|
||||
"email": key_email,
|
||||
"cost": pricing.final_price_rub,
|
||||
"selected_duration_days": pricing.duration_days,
|
||||
"selected_device_limit": pricing.selected_device_limit,
|
||||
"selected_traffic_limit": pricing.selected_traffic_limit,
|
||||
"selected_price_rub": pricing.final_price_rub,
|
||||
"total_gb": pricing.total_gb,
|
||||
"base_price_rub": pricing.base_price_rub,
|
||||
"discount_rub": pricing.discount_rub,
|
||||
"applied_coupon_code": pricing.applied_coupon_code,
|
||||
"coupon_id": pricing.coupon_id,
|
||||
},
|
||||
)
|
||||
payment_result = await create_payment_link(session, payment_request)
|
||||
if not payment_result.success or not payment_result.payment_url or not payment_result.payment_id:
|
||||
raise HTTPException(status_code=400, detail=payment_result.error or "Не удалось создать ссылку оплаты")
|
||||
await create_temporary_data(
|
||||
session,
|
||||
int(billing_user_id),
|
||||
"waiting_for_renewal_payment",
|
||||
{
|
||||
"tariff_id": int(tariff_id),
|
||||
"client_id": str(client_id),
|
||||
"email": key_email,
|
||||
"cost": pricing.final_price_rub,
|
||||
"required_amount": pricing.required_amount,
|
||||
"selected_duration_days": pricing.duration_days,
|
||||
"selected_device_limit": pricing.selected_device_limit,
|
||||
"selected_traffic_limit": pricing.selected_traffic_limit,
|
||||
"selected_price_rub": pricing.final_price_rub,
|
||||
"total_gb": pricing.total_gb,
|
||||
"base_price_rub": pricing.base_price_rub,
|
||||
"discount_rub": pricing.discount_rub,
|
||||
"applied_coupon_code": pricing.applied_coupon_code,
|
||||
"coupon_id": pricing.coupon_id,
|
||||
},
|
||||
)
|
||||
return AccountKeyRenewResponse(
|
||||
ok=True,
|
||||
message="Требуется оплата для продления подписки",
|
||||
client_id=str(client_id),
|
||||
tariff_id=int(tariff_id),
|
||||
charged_rub=0,
|
||||
balance_rub=pricing.balance,
|
||||
base_price_rub=pricing.base_price_rub,
|
||||
discount_rub=pricing.discount_rub,
|
||||
final_price_rub=pricing.final_price_rub,
|
||||
applied_coupon_code=pricing.applied_coupon_code,
|
||||
payment_required=True,
|
||||
required_amount_rub=pricing.required_amount,
|
||||
payment_id=payment_result.payment_id,
|
||||
payment_url=payment_result.payment_url,
|
||||
)
|
||||
expiry_raw = _normalize_expiry_ms(getattr(db_key, "expiry_time", None))
|
||||
now_ms = int(datetime.utcnow().timestamp() * 1000)
|
||||
base_expiry = now_ms if expiry_raw <= now_ms else expiry_raw
|
||||
new_expiry_time = int(base_expiry + pricing.duration_days * 24 * 60 * 60 * 1000)
|
||||
if not key_email or not key_server_id:
|
||||
raise HTTPException(status_code=400, detail="Некорректные данные подписки")
|
||||
try:
|
||||
result = await execute_renewal(
|
||||
session=session,
|
||||
billing_user_id=int(billing_user_id),
|
||||
client_id=str(client_id),
|
||||
key_email=key_email,
|
||||
key_server_id=key_server_id,
|
||||
tariff_id=int(tariff_id),
|
||||
new_expiry_time=new_expiry_time,
|
||||
total_gb=pricing.total_gb,
|
||||
cost=float(pricing.final_price_rub),
|
||||
selected_device_limit=pricing.selected_device_limit,
|
||||
selected_traffic_limit=pricing.selected_traffic_limit,
|
||||
selected_price_rub=pricing.final_price_rub,
|
||||
coupon_id=pricing.coupon_id,
|
||||
)
|
||||
except ServiceError as e:
|
||||
raise HTTPException(status_code=400, detail=e.message)
|
||||
await session.commit()
|
||||
return AccountKeyRenewResponse(
|
||||
ok=True,
|
||||
message="Подписка продлена",
|
||||
client_id=result.client_id,
|
||||
tariff_id=result.tariff_id,
|
||||
charged_rub=result.charged_rub,
|
||||
balance_rub=result.balance_rub,
|
||||
base_price_rub=pricing.base_price_rub,
|
||||
discount_rub=pricing.discount_rub,
|
||||
final_price_rub=pricing.final_price_rub,
|
||||
applied_coupon_code=pricing.applied_coupon_code,
|
||||
)
|
||||
@@ -3,10 +3,12 @@ import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Literal
|
||||
|
||||
import psutil
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
@@ -23,10 +25,11 @@ from api.v2.schemas.audit import (
|
||||
)
|
||||
from audit import drain_audit_redis_to_db, get_audit_funnel, get_audit_stats, list_audit_events
|
||||
from config import API_TOKEN, BOT_SERVICE
|
||||
from database import async_session_maker
|
||||
from core.bootstrap import MANAGEMENT_CONFIG
|
||||
from core.executor import run_io
|
||||
from core.redis_cache import cache_incr
|
||||
from core.settings.management_config import update_management_config
|
||||
from database import async_session_maker
|
||||
from database.models import Key, ScheduledBroadcast, Server, User
|
||||
from database.scheduled_broadcasts import (
|
||||
cancel_scheduled_broadcast,
|
||||
@@ -48,9 +51,18 @@ from handlers.admin.sender.scheduled_service import (
|
||||
from logger import logger
|
||||
from utils.backup import backup_database
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _admin_rate_limit(request_or_identity, action: str, max_calls: int, window_sec: int) -> None:
|
||||
identity_id = getattr(request_or_identity, "id", "unknown")
|
||||
key = f"admin_rl:{action}:{identity_id}"
|
||||
count = await cache_incr(key, window_sec)
|
||||
if count > max_calls:
|
||||
raise HTTPException(status_code=429, detail="Слишком много запросов. Попробуйте позже.")
|
||||
|
||||
|
||||
class MaintenanceUpdate(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
@@ -174,6 +186,7 @@ async def restart_bot(
|
||||
identity=Depends(verify_identity_admin),
|
||||
):
|
||||
"""Запуск перезапуска бота в фоне."""
|
||||
await _admin_rate_limit(identity, "restart", max_calls=3, window_sec=60)
|
||||
background.add_task(_restart_bot)
|
||||
return {"status": "restarting"}
|
||||
|
||||
@@ -185,6 +198,7 @@ async def change_domain(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Массовая замена домена в ключах и remnawave_link."""
|
||||
await _admin_rate_limit(identity, "change_domain", max_calls=3, window_sec=300)
|
||||
domain = payload.domain.strip()
|
||||
if not domain or " " in domain or not re.fullmatch(r"[a-zA-Z0-9.-]+", domain):
|
||||
raise HTTPException(status_code=400, detail="Invalid domain")
|
||||
@@ -211,11 +225,12 @@ async def restore_trials(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Сбрасывает trial=0 у пользователей без ключей."""
|
||||
await _admin_rate_limit(identity, "restore_trials", max_calls=3, window_sec=300)
|
||||
stmt = (
|
||||
update(User)
|
||||
.where(
|
||||
User.trial == 1,
|
||||
~exists(select(Key.tg_id).where(Key.tg_id == User.tg_id)),
|
||||
~exists(select(Key.user_id).where(Key.user_id == User.id)),
|
||||
)
|
||||
.values(trial=0)
|
||||
)
|
||||
@@ -227,6 +242,7 @@ async def restore_trials(
|
||||
@router.post("/backup")
|
||||
async def trigger_backup(identity=Depends(verify_identity_admin)):
|
||||
"""Запуск бэкапа БД в фоне."""
|
||||
await _admin_rate_limit(identity, "backup", max_calls=2, window_sec=300)
|
||||
|
||||
async def _run_backup() -> None:
|
||||
exception = await backup_database()
|
||||
@@ -357,7 +373,7 @@ async def post_audit_drain(identity=Depends(verify_identity_admin_short)):
|
||||
return {"success": True, "drained": count}
|
||||
except Exception as exc:
|
||||
logger.warning("audit-drain failed: {}", exc)
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
raise HTTPException(status_code=500, detail="Внутренняя ошибка при дренаже аудита") from exc
|
||||
|
||||
|
||||
@router.post("/broadcast")
|
||||
@@ -366,6 +382,7 @@ async def launch_broadcast(
|
||||
identity=Depends(verify_identity_admin_short),
|
||||
):
|
||||
"""Запуск рассылки по выбранной аудитории. Сессия БД не держится на время рассылки."""
|
||||
await _admin_rate_limit(identity, "broadcast", max_calls=5, window_sec=300)
|
||||
try:
|
||||
prepared = prepare_broadcast_payload(
|
||||
send_to=payload.send_to,
|
||||
@@ -475,14 +492,16 @@ async def send_broadcast_schedule_now(
|
||||
identity=Depends(verify_identity_admin_short),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
await _admin_rate_limit(identity, "broadcast_now", max_calls=5, window_sec=300)
|
||||
item = await start_scheduled_broadcast(session, broadcast_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=409, detail="Scheduled broadcast can no longer be sent now")
|
||||
try:
|
||||
result = await execute_scheduled_broadcast(item, bot=_get_broadcast_bot())
|
||||
except Exception as exc:
|
||||
logger.error("[Broadcast] send-now failed for {}: {}", broadcast_id, exc)
|
||||
await mark_scheduled_broadcast_failed(session, broadcast_id, str(exc))
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
raise HTTPException(status_code=500, detail="Ошибка при выполнении рассылки") from exc
|
||||
if result.get("success"):
|
||||
item = await mark_scheduled_broadcast_sent(session, broadcast_id, result)
|
||||
else:
|
||||
|
||||
+17
-5
@@ -13,6 +13,7 @@ from api.v2.schemas import (
|
||||
)
|
||||
from api.v2.base_crud import generate_crud_router
|
||||
from database import get_tracking_source_stats
|
||||
from database.access.resolution import resolve_user_optional
|
||||
from database.models import (
|
||||
BlockedUser,
|
||||
ManualBan,
|
||||
@@ -46,7 +47,10 @@ async def get_payments_by_tg_id(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Список платежей по tg_id пользователя."""
|
||||
result = await session.execute(select(Payment).where(Payment.tg_id == tg_id))
|
||||
u = await resolve_user_optional(session, tg_id)
|
||||
if u is None:
|
||||
raise HTTPException(status_code=404, detail="Payments not found")
|
||||
result = await session.execute(select(Payment).where(Payment.user_id == u.id))
|
||||
payments = result.scalars().all()
|
||||
if not payments:
|
||||
raise HTTPException(status_code=404, detail="Payments not found")
|
||||
@@ -59,7 +63,9 @@ router.include_router(
|
||||
schema_response=NotificationResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="tg_id",
|
||||
identifier_field="user_id",
|
||||
parameter_name="tg_id",
|
||||
telegram_path_to_user_id=True,
|
||||
enabled_methods=["get_all", "get_one", "delete"],
|
||||
),
|
||||
prefix="/notifications",
|
||||
@@ -73,7 +79,9 @@ router.include_router(
|
||||
schema_response=ManualBanResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="tg_id",
|
||||
identifier_field="user_id",
|
||||
parameter_name="tg_id",
|
||||
telegram_path_to_user_id=True,
|
||||
enabled_methods=["get_all", "get_one", "delete"],
|
||||
),
|
||||
prefix="/manual-bans",
|
||||
@@ -87,7 +95,9 @@ router.include_router(
|
||||
schema_response=BlockedUserResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="tg_id",
|
||||
identifier_field="user_id",
|
||||
parameter_name="tg_id",
|
||||
telegram_path_to_user_id=True,
|
||||
enabled_methods=["get_all", "get_one", "delete"],
|
||||
),
|
||||
prefix="/blocked-users",
|
||||
@@ -101,7 +111,9 @@ router.include_router(
|
||||
schema_response=TemporaryDataResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="tg_id",
|
||||
identifier_field="user_id",
|
||||
parameter_name="tg_id",
|
||||
telegram_path_to_user_id=True,
|
||||
enabled_methods=["get_all", "get_one", "delete"],
|
||||
),
|
||||
prefix="/temporary-data",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import pkgutil
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
@@ -7,9 +8,11 @@ from pydantic import BaseModel
|
||||
|
||||
from api.depends import verify_identity_admin
|
||||
from core.executor import run_io
|
||||
from logger import logger
|
||||
from utils.modules_loader import _is_safe_module_name
|
||||
from utils.modules_manager import manager
|
||||
|
||||
|
||||
router = APIRouter(prefix="/modules", tags=["Modules"])
|
||||
|
||||
MODULES_DIR = Path(__file__).resolve().parents[3] / "modules"
|
||||
@@ -117,5 +120,6 @@ async def control_module(module_name: str, payload: ModuleAction, identity=Depen
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
logger.error("[Modules] action failed for {}: {}", name, exc)
|
||||
raise HTTPException(status_code=500, detail="Ошибка при выполнении операции модуля") from exc
|
||||
return {"item": _module_state(name)}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_identity_token
|
||||
from database.models import Identity
|
||||
from database import web_notifications as wn_db
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class PushSubscribeRequest(BaseModel):
|
||||
endpoint: str
|
||||
keys: dict
|
||||
|
||||
|
||||
class NotificationItem(BaseModel):
|
||||
id: str
|
||||
type: str
|
||||
title: str
|
||||
message: str
|
||||
read: bool
|
||||
created_at: str
|
||||
data: dict | None = None
|
||||
|
||||
|
||||
class NotificationsResponse(BaseModel):
|
||||
ok: bool = True
|
||||
notifications: list[NotificationItem]
|
||||
unread_count: int
|
||||
|
||||
|
||||
@router.post("/push/subscribe", tags=["Notifications"])
|
||||
async def push_subscribe(
|
||||
body: PushSubscribeRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity: Identity = Depends(verify_identity_token),
|
||||
):
|
||||
user_id = identity.tg_id or 0
|
||||
|
||||
await wn_db.upsert_push_subscription(
|
||||
session,
|
||||
user_id=user_id,
|
||||
identity_id=identity.id,
|
||||
endpoint=body.endpoint,
|
||||
keys_json=body.keys,
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/notifications", response_model=NotificationsResponse, tags=["Notifications"])
|
||||
async def get_notifications(
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity: Identity = Depends(verify_identity_token),
|
||||
):
|
||||
notifications = await wn_db.get_notifications_for_identity(
|
||||
session, identity.id, limit=limit,
|
||||
)
|
||||
unread_count = await wn_db.count_unread_for_identity(session, identity.id)
|
||||
|
||||
items = [
|
||||
NotificationItem(
|
||||
id=n.id,
|
||||
type=n.type,
|
||||
title=n.title,
|
||||
message=n.message,
|
||||
read=n.read,
|
||||
created_at=n.created_at.isoformat() if n.created_at else "",
|
||||
data=n.data,
|
||||
)
|
||||
for n in notifications
|
||||
]
|
||||
return NotificationsResponse(notifications=items, unread_count=unread_count)
|
||||
|
||||
|
||||
@router.post("/notifications/read-all", tags=["Notifications"])
|
||||
async def read_all_notifications(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity: Identity = Depends(verify_identity_token),
|
||||
):
|
||||
count = await wn_db.mark_all_read_for_identity(session, identity.id)
|
||||
return {"ok": True, "updated": count}
|
||||
+465
-3
@@ -1,14 +1,31 @@
|
||||
import csv
|
||||
from base64 import b64encode
|
||||
from datetime import datetime
|
||||
from io import StringIO
|
||||
from io import BytesIO, StringIO
|
||||
import re
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
import qrcode
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_identity_admin
|
||||
from api.depends import get_request_actor, get_session, verify_identity_admin, verify_identity_token
|
||||
from api.v2.schemas.web_public import (
|
||||
PartnerApplyRequest,
|
||||
PartnerApplyResponse,
|
||||
PartnerConditionsResponse,
|
||||
PartnerQrResponse,
|
||||
PartnerTopEntryResponse,
|
||||
PartnerTopResponse,
|
||||
PartnerPayoutEntryResponse,
|
||||
PartnerPayoutHistoryResponse,
|
||||
PartnerPayoutRequestCreate,
|
||||
PartnerPayoutRequestResponse,
|
||||
)
|
||||
from database import identities as idb
|
||||
from utils.referral_codes import decode_partner_code, encode_partner_code
|
||||
|
||||
try:
|
||||
from modules.partner_program.settings import PARTNER_BONUS_PERCENTAGES
|
||||
@@ -44,6 +61,451 @@ def _row_dt_iso(value) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_public_base_url(request: Request) -> str:
|
||||
origin = str(request.headers.get("origin") or "").strip()
|
||||
if origin.startswith("http://") or origin.startswith("https://"):
|
||||
return origin.rstrip("/")
|
||||
referer = str(request.headers.get("referer") or request.headers.get("referrer") or "").strip()
|
||||
if referer.startswith("http://") or referer.startswith("https://"):
|
||||
parsed = urlsplit(referer)
|
||||
if parsed.scheme and parsed.netloc:
|
||||
return f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
|
||||
forwarded_host = str(request.headers.get("x-forwarded-host") or "").strip()
|
||||
host = forwarded_host or str(request.headers.get("host") or "").strip()
|
||||
forwarded_proto = str(request.headers.get("x-forwarded-proto") or "").split(",", 1)[0].strip().lower()
|
||||
scheme = forwarded_proto if forwarded_proto in {"http", "https"} else request.url.scheme
|
||||
if host:
|
||||
return f"{scheme}://{host}".rstrip("/")
|
||||
return str(request.base_url).rstrip("/")
|
||||
|
||||
|
||||
async def _ensure_partner_code(session: AsyncSession, user_id: int, raw_code: str | None) -> str:
|
||||
code = str(raw_code or "").strip()
|
||||
if code and not code.isdigit() and not code.startswith("r1_"):
|
||||
return code
|
||||
generated = encode_partner_code(int(user_id))
|
||||
try:
|
||||
await session.execute(
|
||||
text("UPDATE users SET partner_code = :code WHERE id = :id"),
|
||||
{"code": generated, "id": int(user_id)},
|
||||
)
|
||||
await session.flush()
|
||||
except Exception:
|
||||
pass
|
||||
return generated
|
||||
|
||||
|
||||
async def _resolve_partner_user(session: AsyncSession, request: Request, identity) -> tuple[int, int]:
|
||||
actor = get_request_actor(request)
|
||||
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
|
||||
if billing_user_id is None:
|
||||
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
row = (
|
||||
await session.execute(
|
||||
text("SELECT id, tg_id FROM users WHERE id = :user_id LIMIT 1"),
|
||||
{"user_id": int(billing_user_id)},
|
||||
)
|
||||
).first()
|
||||
if row is None or row[1] is None:
|
||||
raise HTTPException(status_code=400, detail="Партнерский профиль недоступен")
|
||||
return int(row[0]), int(row[1])
|
||||
|
||||
|
||||
async def _resolve_referrer_by_partner_code(session: AsyncSession, partner_code: str) -> tuple[int, int] | None:
|
||||
code = str(partner_code or "").strip()
|
||||
if not code:
|
||||
return None
|
||||
by_code_row = (
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id, tg_id
|
||||
FROM users
|
||||
WHERE lower(COALESCE(partner_code, '')) = lower(:code)
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"code": code},
|
||||
)
|
||||
).first()
|
||||
if by_code_row is not None:
|
||||
user_id = int(by_code_row[0])
|
||||
tg_id = int(by_code_row[1] if by_code_row[1] is not None else by_code_row[0])
|
||||
return user_id, tg_id
|
||||
decoded = decode_partner_code(code)
|
||||
if decoded is None:
|
||||
return None
|
||||
by_id_row = (
|
||||
await session.execute(
|
||||
text("SELECT id, tg_id FROM users WHERE id = :id LIMIT 1"),
|
||||
{"id": int(decoded)},
|
||||
)
|
||||
).first()
|
||||
if by_id_row is not None:
|
||||
user_id = int(by_id_row[0])
|
||||
tg_id = int(by_id_row[1] if by_id_row[1] is not None else by_id_row[0])
|
||||
return user_id, tg_id
|
||||
by_tg_row = (
|
||||
await session.execute(
|
||||
text("SELECT id, tg_id FROM users WHERE tg_id = :tg_id LIMIT 1"),
|
||||
{"tg_id": int(decoded)},
|
||||
)
|
||||
).first()
|
||||
if by_tg_row is not None:
|
||||
user_id = int(by_tg_row[0])
|
||||
tg_id = int(by_tg_row[1] if by_tg_row[1] is not None else by_tg_row[0])
|
||||
return user_id, tg_id
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/apply", response_model=PartnerApplyResponse)
|
||||
async def partner_apply(
|
||||
body: PartnerApplyRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
joined_user_id, joined_tg_id = await _resolve_partner_user(session, request, identity)
|
||||
code_value = str(body.partner_code or "").strip()
|
||||
referrer_user_id: int | None = None
|
||||
referrer_tg_id: int | None = None
|
||||
if code_value:
|
||||
resolved = await _resolve_referrer_by_partner_code(session, code_value)
|
||||
if resolved is not None:
|
||||
referrer_user_id, referrer_tg_id = resolved
|
||||
if referrer_tg_id is None and body.partner_tg_id is not None:
|
||||
referrer_tg_id = int(body.partner_tg_id)
|
||||
referrer_user_id_row = (
|
||||
await session.execute(
|
||||
text("SELECT id FROM users WHERE tg_id = :tg_id LIMIT 1"),
|
||||
{"tg_id": int(referrer_tg_id)},
|
||||
)
|
||||
).first()
|
||||
if referrer_user_id_row is not None:
|
||||
referrer_user_id = int(referrer_user_id_row[0])
|
||||
if referrer_tg_id is None:
|
||||
raise HTTPException(status_code=400, detail="Партнерский код не найден")
|
||||
if int(referrer_tg_id) == int(joined_tg_id):
|
||||
raise HTTPException(status_code=400, detail="Нельзя применить свой партнерский код")
|
||||
already_row = (
|
||||
await session.execute(
|
||||
text("SELECT partner_tg_id FROM partners WHERE joined_tg_id = :joined_tg_id LIMIT 1"),
|
||||
{"joined_tg_id": int(joined_tg_id)},
|
||||
)
|
||||
).first()
|
||||
if already_row is not None and already_row[0] is not None:
|
||||
raise HTTPException(status_code=409, detail="Партнер уже привязан")
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO partners (partner_tg_id, joined_tg_id)
|
||||
VALUES (:partner_tg_id, :joined_tg_id)
|
||||
"""
|
||||
),
|
||||
{"partner_tg_id": int(referrer_tg_id), "joined_tg_id": int(joined_tg_id)},
|
||||
)
|
||||
await session.commit()
|
||||
return PartnerApplyResponse(
|
||||
ok=True,
|
||||
message="Партнерский код применен",
|
||||
partner_code=code_value,
|
||||
partner_user_id=int(referrer_user_id or 0),
|
||||
partner_tg_id=int(referrer_tg_id),
|
||||
joined_user_id=int(joined_user_id),
|
||||
joined_tg_id=int(joined_tg_id),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/qr", response_model=PartnerQrResponse)
|
||||
async def partner_qr(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
user_id, _ = await _resolve_partner_user(session, request, identity)
|
||||
code_row = (
|
||||
await session.execute(
|
||||
text("SELECT partner_code FROM users WHERE id = :id LIMIT 1"),
|
||||
{"id": int(user_id)},
|
||||
)
|
||||
).first()
|
||||
partner_code = await _ensure_partner_code(session, int(user_id), code_row[0] if code_row else None)
|
||||
base_url = _resolve_public_base_url(request)
|
||||
partner_link = f"{base_url}/partner/{partner_code}"
|
||||
qr = qrcode.QRCode(version=1, box_size=10, border=4)
|
||||
qr.add_data(partner_link)
|
||||
qr.make(fit=True)
|
||||
image = qr.make_image(fill_color="black", back_color="white")
|
||||
png_buffer = BytesIO()
|
||||
image.save(png_buffer, format="PNG")
|
||||
image_data = b64encode(png_buffer.getvalue()).decode("ascii")
|
||||
return PartnerQrResponse(
|
||||
ok=True,
|
||||
link=partner_link,
|
||||
image_data_url=f"data:image/png;base64,{image_data}",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/top", response_model=PartnerTopResponse)
|
||||
async def partner_top(
|
||||
request: Request,
|
||||
limit: int = Query(5, ge=1, le=20),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
_, joined_tg_id = await _resolve_partner_user(session, request, identity)
|
||||
user_referred_count_row = (
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT COUNT(DISTINCT joined_tg_id)
|
||||
FROM partners
|
||||
WHERE partner_tg_id = :partner_tg_id
|
||||
"""
|
||||
),
|
||||
{"partner_tg_id": int(joined_tg_id)},
|
||||
)
|
||||
).first()
|
||||
user_referred_count = int(user_referred_count_row[0] or 0) if user_referred_count_row else 0
|
||||
user_position: int | None = None
|
||||
if user_referred_count > 0:
|
||||
user_position_row = (
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT COUNT(*) + 1
|
||||
FROM (
|
||||
SELECT partner_tg_id, COUNT(DISTINCT joined_tg_id) AS referred_count
|
||||
FROM partners
|
||||
WHERE partner_tg_id IS NOT NULL
|
||||
GROUP BY partner_tg_id
|
||||
) ranked
|
||||
WHERE ranked.referred_count > :referred_count
|
||||
"""
|
||||
),
|
||||
{"referred_count": int(user_referred_count)},
|
||||
)
|
||||
).first()
|
||||
user_position = int(user_position_row[0] or 1) if user_position_row else 1
|
||||
top_rows = (
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT
|
||||
COALESCE(u.id, 0) AS partner_user_id,
|
||||
p.partner_tg_id AS partner_tg_id,
|
||||
COUNT(DISTINCT p.joined_tg_id) AS referred_count
|
||||
FROM partners p
|
||||
LEFT JOIN users u ON u.tg_id = p.partner_tg_id
|
||||
WHERE p.partner_tg_id IS NOT NULL
|
||||
GROUP BY p.partner_tg_id, u.id
|
||||
ORDER BY referred_count DESC, p.partner_tg_id ASC
|
||||
LIMIT :limit
|
||||
"""
|
||||
),
|
||||
{"limit": int(limit)},
|
||||
)
|
||||
).all()
|
||||
top: list[PartnerTopEntryResponse] = []
|
||||
for index, row in enumerate(top_rows, 1):
|
||||
partner_user_id = int(row[0] or 0)
|
||||
partner_tg_id = int(row[1] or 0)
|
||||
referred_count = int(row[2] or 0)
|
||||
if partner_user_id > 0:
|
||||
display_id = encode_partner_code(partner_user_id)
|
||||
else:
|
||||
tg_tail = str(partner_tg_id)
|
||||
display_id = f"p_{tg_tail[:2]}***{tg_tail[-2:]}" if tg_tail else "p_***"
|
||||
top.append(
|
||||
PartnerTopEntryResponse(
|
||||
position=index,
|
||||
partner_user_id=partner_user_id,
|
||||
referred_count=referred_count,
|
||||
display_id=display_id,
|
||||
)
|
||||
)
|
||||
return PartnerTopResponse(
|
||||
user_referred_count=user_referred_count,
|
||||
user_position=user_position,
|
||||
top=top,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/conditions", response_model=PartnerConditionsResponse)
|
||||
async def partner_conditions(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
try:
|
||||
from modules.partner_program import settings as partner_settings
|
||||
except Exception:
|
||||
partner_settings = None
|
||||
mode = str(getattr(partner_settings, "REFERRAL_REWARD_MODE", "percent_only") or "percent_only")
|
||||
percent_levels_raw = getattr(partner_settings, "PARTNER_BONUS_PERCENTAGES", {}) or {}
|
||||
flat_levels_raw = getattr(partner_settings, "PARTNER_FLAT_BONUSES", {}) or {}
|
||||
min_payout = float(getattr(partner_settings, "MIN_PARTNER_PAYOUT", 0) or 0)
|
||||
custom_amount_enabled = bool(getattr(partner_settings, "ENABLE_CUSTOM_WITHDRAW_AMOUNT", False))
|
||||
method_map = [
|
||||
("ENABLE_PAYOUT_CARD", "Карта"),
|
||||
("ENABLE_PAYOUT_SBP", "СБП"),
|
||||
("ENABLE_PAYOUT_USDT", "USDT"),
|
||||
("ENABLE_PAYOUT_TON", "TON"),
|
||||
]
|
||||
payout_methods = [
|
||||
title for key, title in method_map if bool(getattr(partner_settings, key, False))
|
||||
] if partner_settings else []
|
||||
level_lines: list[str] = []
|
||||
all_levels = sorted({int(k) for k in [*percent_levels_raw.keys(), *flat_levels_raw.keys()] if str(k).isdigit()})
|
||||
for level in all_levels:
|
||||
parts: list[str] = []
|
||||
if level in percent_levels_raw:
|
||||
try:
|
||||
parts.append(f"{float(percent_levels_raw[level]) * 100:.0f}%")
|
||||
except Exception:
|
||||
pass
|
||||
if level in flat_levels_raw:
|
||||
try:
|
||||
parts.append(f"{float(flat_levels_raw[level]):.0f} RUB")
|
||||
except Exception:
|
||||
pass
|
||||
if parts:
|
||||
level_lines.append(f"{level} уровень: {' + '.join(parts)}")
|
||||
if not level_lines:
|
||||
level_lines = ["1 уровень: бонус определяется настройками проекта"]
|
||||
mode_labels = {
|
||||
"percent_only": "Процент с каждого пополнения приглашенного",
|
||||
"flat_only": "Фиксированный бонус за первую оплату приглашенного",
|
||||
"flat_plus_percent": "Фиксированный бонус за первую оплату и процент с пополнений",
|
||||
}
|
||||
rules = [
|
||||
"Вознаграждение начисляется только после успешной оплаты приглашенного пользователя.",
|
||||
"Самореферал и самопартнерство недоступны.",
|
||||
f"Минимальная сумма вывода: {min_payout:.0f} RUB." if min_payout > 0 else "Вывод доступен по правилам проекта.",
|
||||
]
|
||||
if payout_methods:
|
||||
rules.append(f"Доступные способы вывода: {', '.join(payout_methods)}.")
|
||||
examples = [
|
||||
"Пример: приглашенный пополнил на 1000 RUB, а ставка 15% — вы получаете 150 RUB.",
|
||||
"Пример: приглашенный сделал несколько пополнений, бонус считается по каждой успешной операции.",
|
||||
]
|
||||
return PartnerConditionsResponse(
|
||||
title="Условия партнерской программы",
|
||||
summary="Актуальные условия и режим начислений для партнеров.",
|
||||
bonus_mode=mode,
|
||||
bonus_mode_label=mode_labels.get(mode, mode_labels["percent_only"]),
|
||||
level_lines=level_lines,
|
||||
rules=rules,
|
||||
examples=examples,
|
||||
min_payout_rub=min_payout,
|
||||
payout_methods=payout_methods,
|
||||
custom_amount_enabled=custom_amount_enabled,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/payouts/me", response_model=PartnerPayoutHistoryResponse)
|
||||
async def partner_payouts_me(
|
||||
request: Request,
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
_, tg_id = await _resolve_partner_user(session, request, identity)
|
||||
count_sql = text("SELECT COUNT(*) FROM payout_requests WHERE tg_id = :tg_id")
|
||||
rows_sql = text(
|
||||
"""
|
||||
SELECT id, amount, status, created_at, method, destination
|
||||
FROM payout_requests
|
||||
WHERE tg_id = :tg_id
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""
|
||||
)
|
||||
total = int((await session.scalar(count_sql, {"tg_id": tg_id})) or 0)
|
||||
rows = (
|
||||
await session.execute(rows_sql, {"tg_id": tg_id, "limit": int(limit), "offset": int(offset)})
|
||||
).fetchall()
|
||||
items = [
|
||||
PartnerPayoutEntryResponse(
|
||||
id=int(row[0]),
|
||||
amount_rub=float(row[1] or 0.0),
|
||||
status=str(row[2] or ""),
|
||||
created_at=_row_dt_iso(row[3]),
|
||||
method=row[4] or None,
|
||||
destination=row[5] or None,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
return PartnerPayoutHistoryResponse(total=total, items=items)
|
||||
|
||||
|
||||
@router.post("/payouts/me", response_model=PartnerPayoutRequestResponse)
|
||||
async def partner_create_payout_request(
|
||||
body: PartnerPayoutRequestCreate,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
user_id, tg_id = await _resolve_partner_user(session, request, identity)
|
||||
row = (
|
||||
await session.execute(
|
||||
text("SELECT COALESCE(partner_balance, 0), payout_method, card_number FROM users WHERE id = :id"),
|
||||
{"id": user_id},
|
||||
)
|
||||
).first()
|
||||
balance = float(row[0] or 0.0) if row else 0.0
|
||||
requested = float(body.amount_rub)
|
||||
if requested <= 0:
|
||||
raise HTTPException(status_code=400, detail="Сумма должна быть больше нуля")
|
||||
try:
|
||||
from modules.partner_program.settings import ENABLE_CUSTOM_WITHDRAW_AMOUNT, MIN_PARTNER_PAYOUT
|
||||
except Exception:
|
||||
ENABLE_CUSTOM_WITHDRAW_AMOUNT = True
|
||||
MIN_PARTNER_PAYOUT = 0
|
||||
min_payout = float(MIN_PARTNER_PAYOUT or 0)
|
||||
if requested < min_payout:
|
||||
raise HTTPException(status_code=400, detail=f"Минимальная сумма вывода — {min_payout:.0f} RUB")
|
||||
if not bool(ENABLE_CUSTOM_WITHDRAW_AMOUNT):
|
||||
requested = balance
|
||||
if requested > balance:
|
||||
raise HTTPException(status_code=400, detail="Недостаточно партнерского баланса")
|
||||
if requested <= 0:
|
||||
raise HTTPException(status_code=400, detail="Недостаточно средств для заявки")
|
||||
payout_method = (row[1] if row else None) or "card"
|
||||
destination = (row[2] if row else None) or None
|
||||
inserted = (
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO payout_requests (tg_id, amount, status, created_at, method, destination)
|
||||
VALUES (:tg_id, :amount, 'pending', NOW(), :method, :destination)
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"tg_id": int(tg_id),
|
||||
"amount": float(requested),
|
||||
"method": payout_method,
|
||||
"destination": destination,
|
||||
},
|
||||
)
|
||||
).scalar()
|
||||
new_balance = balance - requested
|
||||
await session.execute(
|
||||
text("UPDATE users SET partner_balance = :balance WHERE id = :id"),
|
||||
{"balance": new_balance, "id": int(user_id)},
|
||||
)
|
||||
await session.commit()
|
||||
return PartnerPayoutRequestResponse(
|
||||
ok=True,
|
||||
message="Заявка на вывод создана",
|
||||
request_id=int(inserted) if inserted is not None else None,
|
||||
amount_rub=float(requested),
|
||||
status="pending",
|
||||
balance_rub=float(new_balance),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/all")
|
||||
async def get_all_partners(
|
||||
limit: int = Query(1000, ge=1, le=10000, description="Лимит результатов"),
|
||||
|
||||
+240
-23
@@ -1,28 +1,143 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_identity_token
|
||||
from api.v2.schemas.payment_links import PaymentLinkCreateRequest, PaymentLinkCreateResponse
|
||||
from database import identities as idb
|
||||
from handlers.payments import create_payment_link
|
||||
from handlers.payments.payment_links import PaymentLinkRequest
|
||||
from api.v2.schemas.payment_links import PaymentLinkCreateRequest, PaymentLinkCreateResponse, PaymentLinkStatusResponse
|
||||
from config import REDIS_URL
|
||||
from database import (
|
||||
async_session_maker,
|
||||
get_payment_by_payment_id,
|
||||
get_payment_from_db_by_payment_id,
|
||||
identities as idb,
|
||||
)
|
||||
from database.temporary_data import create_temporary_data
|
||||
from logger import logger
|
||||
from services.payments.payment_events import payment_events_channel
|
||||
from services.payments.payment_links import PaymentLinkRequest, create_payment_link
|
||||
|
||||
|
||||
router = APIRouter(tags=["PaymentLinks"])
|
||||
|
||||
|
||||
async def _resolve_tg_id(body: PaymentLinkCreateRequest, session: AsyncSession) -> int:
|
||||
"""Возвращает tg_id из body.tg_id или из identity_id; иначе исключение."""
|
||||
if body.tg_id is not None:
|
||||
return body.tg_id
|
||||
if body.identity_id:
|
||||
tg_id = await idb.resolve_tg_id(session, body.identity_id)
|
||||
if tg_id is not None:
|
||||
return tg_id
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="У идентичности не привязан Telegram. Привяжите tg_id для создания платёжной ссылки.",
|
||||
)
|
||||
raise HTTPException(status_code=400, detail="Укажите tg_id или identity_id")
|
||||
async def _store_payment_intent(
|
||||
session: AsyncSession,
|
||||
billing_user_ref: int,
|
||||
metadata: dict | None,
|
||||
amount: int | float,
|
||||
) -> None:
|
||||
if not isinstance(metadata, dict):
|
||||
return
|
||||
payment_flow = str(metadata.get("payment_flow") or "").strip().lower()
|
||||
required_amount = int(round(float(amount)))
|
||||
if payment_flow == "tariff_purchase":
|
||||
tariff_id = metadata.get("tariff_id")
|
||||
if tariff_id in (None, ""):
|
||||
return
|
||||
payload: dict[str, int | str] = {
|
||||
"tariff_id": int(tariff_id),
|
||||
"required_amount": required_amount,
|
||||
"selected_price_rub": int(metadata.get("selected_price_rub") or required_amount),
|
||||
}
|
||||
selected_device_limit = metadata.get("selected_device_limit")
|
||||
if selected_device_limit not in (None, ""):
|
||||
payload["selected_device_limit"] = int(selected_device_limit)
|
||||
selected_traffic_gb = metadata.get("selected_traffic_gb")
|
||||
if selected_traffic_gb not in (None, ""):
|
||||
payload["selected_traffic_limit_gb"] = int(selected_traffic_gb)
|
||||
selected_duration_days = metadata.get("selected_duration_days")
|
||||
if selected_duration_days not in (None, ""):
|
||||
payload["selected_duration_days"] = int(selected_duration_days)
|
||||
coupon_id = metadata.get("coupon_id")
|
||||
if coupon_id not in (None, ""):
|
||||
payload["coupon_id"] = int(coupon_id)
|
||||
discount_rub = metadata.get("discount_rub")
|
||||
if discount_rub not in (None, ""):
|
||||
payload["discount_rub"] = int(discount_rub)
|
||||
base_price_rub = metadata.get("base_price_rub")
|
||||
if base_price_rub not in (None, ""):
|
||||
payload["base_price_rub"] = int(base_price_rub)
|
||||
applied_coupon_code = metadata.get("applied_coupon_code")
|
||||
if applied_coupon_code not in (None, ""):
|
||||
payload["applied_coupon_code"] = str(applied_coupon_code)
|
||||
await create_temporary_data(session, billing_user_ref, "waiting_for_payment", payload)
|
||||
return
|
||||
if payment_flow == "key_renewal":
|
||||
required_fields = ("tariff_id", "client_id", "email", "cost")
|
||||
if any(metadata.get(field) in (None, "") for field in required_fields):
|
||||
return
|
||||
payload: dict[str, int | str] = {
|
||||
"tariff_id": int(metadata["tariff_id"]),
|
||||
"client_id": str(metadata["client_id"]),
|
||||
"email": str(metadata["email"]),
|
||||
"cost": int(metadata["cost"]),
|
||||
"required_amount": required_amount,
|
||||
"selected_price_rub": int(metadata.get("selected_price_rub") or metadata["cost"]),
|
||||
}
|
||||
selected_duration_days = metadata.get("selected_duration_days")
|
||||
if selected_duration_days not in (None, ""):
|
||||
payload["selected_duration_days"] = int(selected_duration_days)
|
||||
selected_device_limit = metadata.get("selected_device_limit")
|
||||
if selected_device_limit not in (None, ""):
|
||||
payload["selected_device_limit"] = int(selected_device_limit)
|
||||
selected_traffic_limit = metadata.get("selected_traffic_limit")
|
||||
if selected_traffic_limit not in (None, ""):
|
||||
payload["selected_traffic_limit"] = int(selected_traffic_limit)
|
||||
total_gb = metadata.get("total_gb")
|
||||
if total_gb not in (None, ""):
|
||||
payload["total_gb"] = int(total_gb)
|
||||
coupon_id = metadata.get("coupon_id")
|
||||
if coupon_id not in (None, ""):
|
||||
payload["coupon_id"] = int(coupon_id)
|
||||
discount_rub = metadata.get("discount_rub")
|
||||
if discount_rub not in (None, ""):
|
||||
payload["discount_rub"] = int(discount_rub)
|
||||
base_price_rub = metadata.get("base_price_rub")
|
||||
if base_price_rub not in (None, ""):
|
||||
payload["base_price_rub"] = int(base_price_rub)
|
||||
applied_coupon_code = metadata.get("applied_coupon_code")
|
||||
if applied_coupon_code not in (None, ""):
|
||||
payload["applied_coupon_code"] = str(applied_coupon_code)
|
||||
await create_temporary_data(session, billing_user_ref, "waiting_for_renewal_payment", payload)
|
||||
return
|
||||
if payment_flow == "key_addons":
|
||||
required_fields = ("tariff_id", "email", "original_price")
|
||||
if any(metadata.get(field) in (None, "") for field in required_fields):
|
||||
return
|
||||
payload: dict[str, int | str] = {
|
||||
"tariff_id": int(metadata["tariff_id"]),
|
||||
"email": str(metadata["email"]),
|
||||
"original_price": int(metadata["original_price"]),
|
||||
"required_amount": required_amount,
|
||||
}
|
||||
selected_device_limit = metadata.get("selected_device_limit")
|
||||
if selected_device_limit not in (None, ""):
|
||||
payload["selected_device_limit"] = int(selected_device_limit)
|
||||
selected_traffic_gb = metadata.get("selected_traffic_gb")
|
||||
if selected_traffic_gb not in (None, ""):
|
||||
payload["selected_traffic_gb"] = int(selected_traffic_gb)
|
||||
current_device_limit = metadata.get("current_device_limit")
|
||||
if current_device_limit not in (None, ""):
|
||||
payload["current_device_limit"] = int(current_device_limit)
|
||||
current_traffic_gb = metadata.get("current_traffic_gb")
|
||||
if current_traffic_gb not in (None, ""):
|
||||
payload["current_traffic_gb"] = int(current_traffic_gb)
|
||||
coupon_id = metadata.get("coupon_id")
|
||||
if coupon_id not in (None, ""):
|
||||
payload["coupon_id"] = int(coupon_id)
|
||||
discount_rub = metadata.get("discount_rub")
|
||||
if discount_rub not in (None, ""):
|
||||
payload["discount_rub"] = int(discount_rub)
|
||||
base_price_rub = metadata.get("base_price_rub")
|
||||
if base_price_rub not in (None, ""):
|
||||
payload["base_price_rub"] = int(base_price_rub)
|
||||
applied_coupon_code = metadata.get("applied_coupon_code")
|
||||
if applied_coupon_code not in (None, ""):
|
||||
payload["applied_coupon_code"] = str(applied_coupon_code)
|
||||
await create_temporary_data(session, billing_user_ref, "waiting_for_addons_payment", payload)
|
||||
|
||||
|
||||
@router.post("/", response_model=PaymentLinkCreateResponse)
|
||||
@@ -32,13 +147,10 @@ async def create_link(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
"""Создаёт платёжную ссылку через выбранную кассу (единая точка входа). Принимает identity_id или tg_id."""
|
||||
try:
|
||||
tg_id = await _resolve_tg_id(body, session)
|
||||
except HTTPException:
|
||||
raise
|
||||
"""Создаёт платёжную ссылку для текущего авторизованного пользователя."""
|
||||
billing_user_ref = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
payment_request = PaymentLinkRequest(
|
||||
tg_id=tg_id,
|
||||
legacy_user_ref=billing_user_ref,
|
||||
amount=body.amount,
|
||||
currency=body.currency or "RUB",
|
||||
provider_id=body.provider_id,
|
||||
@@ -47,9 +159,114 @@ async def create_link(
|
||||
metadata=body.metadata,
|
||||
)
|
||||
result = await create_payment_link(session, payment_request)
|
||||
if result.success:
|
||||
await _store_payment_intent(
|
||||
session=session,
|
||||
billing_user_ref=billing_user_ref,
|
||||
metadata=body.metadata,
|
||||
amount=body.amount,
|
||||
)
|
||||
return PaymentLinkCreateResponse(
|
||||
success=result.success,
|
||||
payment_id=result.payment_id,
|
||||
payment_url=result.payment_url,
|
||||
error=result.error,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stream")
|
||||
async def payment_events_stream(
|
||||
request: Request,
|
||||
x_identity_id: str = "",
|
||||
token: str = "",
|
||||
):
|
||||
identity_id = str(request.headers.get("X-Identity-Id") or x_identity_id or "").strip()
|
||||
token = str(request.headers.get("X-Token") or token or "").strip()
|
||||
if not identity_id or not token:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
async with async_session_maker() as session:
|
||||
identity = await idb.verify_identity_token(session, identity_id, token)
|
||||
if not identity:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
billing_user_ref = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
await session.commit()
|
||||
|
||||
async def event_generator():
|
||||
redis_client = None
|
||||
pubsub = None
|
||||
channel = payment_events_channel(int(billing_user_ref))
|
||||
try:
|
||||
from redis.asyncio import from_url
|
||||
|
||||
redis_client = from_url(REDIS_URL, encoding="utf-8", decode_responses=True, max_connections=8)
|
||||
pubsub = redis_client.pubsub(ignore_subscribe_messages=True)
|
||||
await pubsub.subscribe(channel)
|
||||
logger.info(f"[Payments] SSE subscribed: user_ref={billing_user_ref}, channel={channel}")
|
||||
yield "retry: 1500\n\n"
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
logger.info(f"[Payments] SSE disconnected by client: user_ref={billing_user_ref}")
|
||||
break
|
||||
message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=15.0)
|
||||
if message and message.get("type") == "message":
|
||||
raw_data = message.get("data")
|
||||
payload = json.loads(raw_data) if isinstance(raw_data, str) else raw_data
|
||||
if isinstance(payload, dict):
|
||||
logger.info(
|
||||
f"[Payments] SSE emit: user_ref={billing_user_ref}, "
|
||||
f"status={payload.get('status')}, flow={payload.get('flow')}"
|
||||
)
|
||||
yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
||||
continue
|
||||
yield ": keepalive\n\n"
|
||||
await asyncio.sleep(0.1)
|
||||
finally:
|
||||
if pubsub is not None:
|
||||
try:
|
||||
await pubsub.unsubscribe(channel)
|
||||
await pubsub.close()
|
||||
except Exception:
|
||||
pass
|
||||
if redis_client is not None:
|
||||
try:
|
||||
await redis_client.aclose()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return StreamingResponse(
|
||||
event_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{payment_id}", response_model=PaymentLinkStatusResponse)
|
||||
async def get_link_status(
|
||||
payment_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
billing_user_ref = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
payment = await get_payment_from_db_by_payment_id(session, payment_id)
|
||||
if payment is None:
|
||||
payment = await get_payment_by_payment_id(session, payment_id)
|
||||
if not payment:
|
||||
raise HTTPException(status_code=404, detail="Payment not found")
|
||||
owner_ref = payment.get("user_id")
|
||||
if owner_ref is None:
|
||||
owner_ref = payment.get("tg_id")
|
||||
if owner_ref is None or int(owner_ref) != int(billing_user_ref):
|
||||
raise HTTPException(status_code=404, detail="Payment not found")
|
||||
status = str(payment.get("status") or "").lower() or None
|
||||
return PaymentLinkStatusResponse(
|
||||
success=True,
|
||||
payment_id=payment_id,
|
||||
status=status,
|
||||
completed=status in {"success", "failed", "cancelled"},
|
||||
paid=status == "success",
|
||||
)
|
||||
|
||||
+179
-29
@@ -1,37 +1,187 @@
|
||||
from fastapi import Depends, HTTPException, Query
|
||||
from sqlalchemy import select
|
||||
from base64 import b64encode
|
||||
from io import BytesIO
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import qrcode
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_identity_admin
|
||||
from api.v2.base_crud import generate_crud_router
|
||||
from api.v2.schemas import ReferralResponse
|
||||
from database.models import Referral
|
||||
|
||||
router = generate_crud_router(
|
||||
model=Referral,
|
||||
schema_response=ReferralResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="referrer_tg_id",
|
||||
parameter_name="referrer_tg_id",
|
||||
enabled_methods=["get_all", "get_one", "get_all_by_field"],
|
||||
from api.depends import get_session, verify_identity_token
|
||||
from api.v2.schemas.web_public import (
|
||||
ReferralApplyRequest,
|
||||
ReferralApplyResponse,
|
||||
ReferralConditionsResponse,
|
||||
ReferralQrResponse,
|
||||
ReferralTopEntryResponse,
|
||||
ReferralTopResponse,
|
||||
)
|
||||
from config import CHECK_REFERRAL_REWARD_ISSUED, REFERRAL_BONUS_PERCENTAGES, REFERRAL_BUTTON, REFERRAL_QR, TOP_REFERRAL_BUTTON
|
||||
from core.bootstrap import BUTTONS_CONFIG
|
||||
from database import add_referral, get_referral_by_referred_id, get_user_referral_count
|
||||
from database.referrals import get_referral_position, get_top_referrals
|
||||
from database import identities as idb
|
||||
from database.access.resolution import resolve_user_optional
|
||||
from utils.referral_codes import decode_referral_code, encode_referral_code
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.delete("/one")
|
||||
async def delete_one_referral(
|
||||
referrer_tg_id: int = Query(..., description="ID пригласившего"),
|
||||
referred_tg_id: int = Query(..., description="ID приглашённого"),
|
||||
identity=Depends(verify_identity_admin),
|
||||
def _normalize_referrer_code(value: str | None, fallback_tg_id: int | None) -> int | None:
|
||||
raw = str(value or "").strip()
|
||||
if raw:
|
||||
if "/referral/" in raw:
|
||||
raw = raw.split("/referral/", 1)[-1]
|
||||
if "start=referral_" in raw:
|
||||
raw = raw.split("start=referral_", 1)[-1]
|
||||
raw = raw.split("?", 1)[0].split("#", 1)[0].strip()
|
||||
parsed = decode_referral_code(raw)
|
||||
if parsed is not None:
|
||||
return parsed
|
||||
if fallback_tg_id is not None and int(fallback_tg_id) > 0:
|
||||
return int(fallback_tg_id)
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_public_base_url(request: Request) -> str:
|
||||
origin = str(request.headers.get("origin") or "").strip()
|
||||
if origin.startswith("http://") or origin.startswith("https://"):
|
||||
return origin.rstrip("/")
|
||||
referer = str(request.headers.get("referer") or request.headers.get("referrer") or "").strip()
|
||||
if referer.startswith("http://") or referer.startswith("https://"):
|
||||
parsed = urlsplit(referer)
|
||||
if parsed.scheme and parsed.netloc:
|
||||
return f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
|
||||
forwarded_host = str(request.headers.get("x-forwarded-host") or "").strip()
|
||||
host = forwarded_host or str(request.headers.get("host") or "").strip()
|
||||
forwarded_proto = str(request.headers.get("x-forwarded-proto") or "").split(",", 1)[0].strip().lower()
|
||||
scheme = forwarded_proto if forwarded_proto in {"http", "https"} else request.url.scheme
|
||||
if host:
|
||||
return f"{scheme}://{host}".rstrip("/")
|
||||
return str(request.base_url).rstrip("/")
|
||||
|
||||
|
||||
@router.post("/apply", response_model=ReferralApplyResponse, tags=["Referrals"])
|
||||
async def apply_referral(
|
||||
body: ReferralApplyRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
"""Удаляет одну связь реферала по паре referrer/referred."""
|
||||
result = await session.execute(
|
||||
select(Referral).where(Referral.referrer_tg_id == referrer_tg_id, Referral.referred_tg_id == referred_tg_id)
|
||||
if not bool(BUTTONS_CONFIG.get("REFERRAL_BUTTON_ENABLED", REFERRAL_BUTTON)):
|
||||
raise HTTPException(status_code=403, detail="Реферальная программа отключена")
|
||||
billing_uid = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
referrer_legacy = _normalize_referrer_code(body.referrer_code, body.referrer_tg_id)
|
||||
if referrer_legacy is None:
|
||||
raise HTTPException(status_code=400, detail="Приглашение недействительно")
|
||||
referrer_u = await resolve_user_optional(session, referrer_legacy)
|
||||
if referrer_u is None:
|
||||
raise HTTPException(status_code=400, detail="Приглашение недействительно")
|
||||
if billing_uid == referrer_u.id:
|
||||
raise HTTPException(status_code=400, detail="Нельзя использовать собственную ссылку")
|
||||
if await get_referral_by_referred_id(session, billing_uid):
|
||||
raise HTTPException(status_code=409, detail="Реферальная связь уже сохранена")
|
||||
await add_referral(session, billing_uid, referrer_u.id)
|
||||
referred_u = await resolve_user_optional(session, billing_uid)
|
||||
return ReferralApplyResponse(
|
||||
ok=True,
|
||||
message="Приглашение применено",
|
||||
referrer_code=str(referrer_u.id),
|
||||
referrer_user_id=int(referrer_u.id),
|
||||
referrer_tg_id=referrer_u.tg_id,
|
||||
referred_user_id=int(billing_uid),
|
||||
referred_tg_id=referred_u.tg_id if referred_u is not None else None,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/top", response_model=ReferralTopResponse, tags=["Referrals"])
|
||||
async def referral_top(
|
||||
limit: int = Query(5, ge=1, le=20),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
if not bool(BUTTONS_CONFIG.get("REFERRAL_BUTTON_ENABLED", REFERRAL_BUTTON)):
|
||||
raise HTTPException(status_code=403, detail="Реферальная программа отключена")
|
||||
if not bool(BUTTONS_CONFIG.get("TOP_REFERRAL_BUTTON_ENABLE", TOP_REFERRAL_BUTTON)):
|
||||
raise HTTPException(status_code=403, detail="Топ рефералов отключен в настройках")
|
||||
billing_uid = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
user_referral_count = int(await get_user_referral_count(session, billing_uid))
|
||||
user_position = int(await get_referral_position(session, user_referral_count)) if user_referral_count > 0 else None
|
||||
top_rows = await get_top_referrals(session, limit=limit)
|
||||
top: list[ReferralTopEntryResponse] = []
|
||||
for index, row in enumerate(top_rows, 1):
|
||||
referrer_user_id = int(row.get("referrer_user_id") or 0)
|
||||
referrals_count = int(row.get("referral_count") or 0)
|
||||
display_id = encode_referral_code(referrer_user_id)
|
||||
top.append(
|
||||
ReferralTopEntryResponse(
|
||||
position=index,
|
||||
referrer_user_id=referrer_user_id,
|
||||
referrals_count=referrals_count,
|
||||
display_id=display_id,
|
||||
)
|
||||
)
|
||||
return ReferralTopResponse(
|
||||
user_referrals_count=user_referral_count,
|
||||
user_position=user_position,
|
||||
top=top,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/qr", response_model=ReferralQrResponse, tags=["Referrals"])
|
||||
async def referral_qr(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
if not bool(BUTTONS_CONFIG.get("REFERRAL_BUTTON_ENABLED", REFERRAL_BUTTON)):
|
||||
raise HTTPException(status_code=403, detail="Реферальная программа отключена")
|
||||
if not bool(BUTTONS_CONFIG.get("REFERRAL_QR_BUTTON_ENABLE", REFERRAL_QR)):
|
||||
raise HTTPException(status_code=403, detail="QR реферальной ссылки отключен в настройках")
|
||||
billing_uid = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
base_url = _resolve_public_base_url(request)
|
||||
referral_link = f"{base_url}/referral/{encode_referral_code(int(billing_uid))}"
|
||||
qr = qrcode.QRCode(version=1, box_size=10, border=4)
|
||||
qr.add_data(referral_link)
|
||||
qr.make(fit=True)
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
buffer = BytesIO()
|
||||
img.save(buffer, format="PNG")
|
||||
image_data = b64encode(buffer.getvalue()).decode("ascii")
|
||||
return ReferralQrResponse(
|
||||
ok=True,
|
||||
link=referral_link,
|
||||
image_data_url=f"data:image/png;base64,{image_data}",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/conditions", response_model=ReferralConditionsResponse, tags=["Referrals"])
|
||||
async def referral_conditions(
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
if not bool(BUTTONS_CONFIG.get("REFERRAL_BUTTON_ENABLED", REFERRAL_BUTTON)):
|
||||
raise HTTPException(status_code=403, detail="Реферальная программа отключена")
|
||||
del identity
|
||||
level_lines: list[str] = []
|
||||
for level in sorted(REFERRAL_BONUS_PERCENTAGES.keys()):
|
||||
value = REFERRAL_BONUS_PERCENTAGES[level]
|
||||
if isinstance(value, float):
|
||||
label = f"{int(value * 100)}% от суммы оплаты"
|
||||
else:
|
||||
label = f"{float(value):g} RUB"
|
||||
level_lines.append(f"{level} уровень: {label}")
|
||||
one_time_mode = bool(CHECK_REFERRAL_REWARD_ISSUED)
|
||||
bonus_mode = "one_time" if one_time_mode else "each_payment"
|
||||
bonus_mode_label = "Бонус за первую успешную оплату реферала" if one_time_mode else "Бонус за каждую успешную оплату реферала"
|
||||
rules = [
|
||||
"Бонус начисляется только за реальных приглашённых пользователей.",
|
||||
"Нельзя использовать собственную реферальную ссылку.",
|
||||
"Реферальную связь можно применить только один раз.",
|
||||
"Размер бонуса зависит от уровня реферальной программы.",
|
||||
]
|
||||
return ReferralConditionsResponse(
|
||||
title="Условия реферальной программы",
|
||||
summary=f"Режим начисления: {bonus_mode_label}.",
|
||||
bonus_mode=bonus_mode,
|
||||
bonus_mode_label=bonus_mode_label,
|
||||
level_lines=level_lines,
|
||||
rules=rules,
|
||||
)
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail="Referral not found")
|
||||
await session.delete(obj)
|
||||
await session.commit()
|
||||
return {"status": "deleted_one"}
|
||||
|
||||
+138
-1
@@ -1,10 +1,63 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from config import PROJECT_NAME, USERNAME_BOT
|
||||
from config import (
|
||||
BALANCE_BUTTON,
|
||||
CAPTCHA_ENABLE,
|
||||
CHANNEL_EXISTS,
|
||||
CHANNEL_REQUIRED,
|
||||
DONATIONS_ENABLE,
|
||||
GIFT_BUTTON,
|
||||
HAPP_CRYPTOLINK,
|
||||
HWID_RESET_BUTTON,
|
||||
INSTRUCTIONS_BUTTON,
|
||||
PROJECT_NAME,
|
||||
REFERRAL_BUTTON,
|
||||
REFERRAL_QR,
|
||||
REMNAWAVE_WEBAPP,
|
||||
REMNAWAVE_WEBAPP_OPEN_IN_BROWSER,
|
||||
TOP_REFERRAL_BUTTON,
|
||||
TRIAL_TIME_DISABLE,
|
||||
USE_COUNTRY_SELECTION,
|
||||
USERNAME_BOT,
|
||||
TELEGRAM_WEBAPP_DIRECT_LINK,
|
||||
TELEGRAM_WEBAPP_SHORT_NAME,
|
||||
)
|
||||
from core.bootstrap import BUTTONS_CONFIG, MODES_CONFIG, MONEY_CONFIG, PAYMENTS_CONFIG
|
||||
from core.settings.web_config import WEB_CONFIG
|
||||
from core.settings.money_config import get_currency_mode
|
||||
from services.payments.providers import PROVIDERS_BASE, TELEGRAM_ONLY_PROVIDER_IDS, WEB_LINK_PROVIDER_IDS
|
||||
|
||||
router = APIRouter(tags=["Root"])
|
||||
|
||||
|
||||
def _telegram_web_app_return_base() -> str | None:
|
||||
direct = str(TELEGRAM_WEBAPP_DIRECT_LINK or "").strip().rstrip("/")
|
||||
if direct:
|
||||
if direct.lower().startswith("http://"):
|
||||
direct = "https://" + direct[7:]
|
||||
if direct.lower().startswith("https://t.me/"):
|
||||
return direct
|
||||
bot = USERNAME_BOT.replace("@", "").strip()
|
||||
sn = str(TELEGRAM_WEBAPP_SHORT_NAME or "").strip()
|
||||
if bot and sn:
|
||||
return f"https://t.me/{bot}/{sn}"
|
||||
if bot:
|
||||
return f"https://t.me/{bot}"
|
||||
return None
|
||||
|
||||
|
||||
def _partner_feature_enabled() -> bool:
|
||||
try:
|
||||
from modules.partner_program import settings as partner_settings
|
||||
except Exception:
|
||||
return False
|
||||
for key in ("PARTNER_PROGRAM_ENABLED", "PARTNER_BUTTON_ENABLED", "PARTNER_ENABLED"):
|
||||
value = getattr(partner_settings, key, None)
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return True
|
||||
|
||||
|
||||
@router.get("/api", include_in_schema=False)
|
||||
async def root():
|
||||
return {"message": "SoloBot API v2", "docs": "/api/docs"}
|
||||
@@ -22,3 +75,87 @@ async def telegram_widget_bot():
|
||||
"bot_username": USERNAME_BOT.replace("@", ""),
|
||||
"project_name": (PROJECT_NAME or "Solo").strip() if isinstance(PROJECT_NAME, str) else "Solo",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/site-config", include_in_schema=True)
|
||||
async def site_config():
|
||||
"""Настройки витрины и кабинета для веб-клиента (флаги из runtime-конфигов бота)."""
|
||||
bot_username = USERNAME_BOT.replace("@", "").strip()
|
||||
pay_flags = {name: bool(PAYMENTS_CONFIG.get(name)) for name in PROVIDERS_BASE}
|
||||
any_pay = any(pay_flags.values())
|
||||
web_link_provider_ids = [provider_id for provider_id in WEB_LINK_PROVIDER_IDS if pay_flags.get(provider_id, False)]
|
||||
telegram_only_provider_ids = [
|
||||
provider_id for provider_id in TELEGRAM_ONLY_PROVIDER_IDS if pay_flags.get(provider_id, False)
|
||||
]
|
||||
currency_mode, currency_one_screen = get_currency_mode()
|
||||
try:
|
||||
cb_raw = MONEY_CONFIG.get("CASHBACK", 0)
|
||||
cashback_percent = float(cb_raw) if cb_raw not in (None, False) else 0.0
|
||||
except (TypeError, ValueError):
|
||||
cashback_percent = 0.0
|
||||
|
||||
webapp_short = str(TELEGRAM_WEBAPP_SHORT_NAME or "").strip() or None
|
||||
webapp_return_base = _telegram_web_app_return_base()
|
||||
return {
|
||||
"bot_username": bot_username or None,
|
||||
"telegram_web_app_short_name": webapp_short,
|
||||
"telegram_web_app_return_base": webapp_return_base,
|
||||
"project_name": (PROJECT_NAME or "Solo").strip() if isinstance(PROJECT_NAME, str) else "Solo",
|
||||
"site_mode": str(WEB_CONFIG.get("SITE_MODE", "full")).strip() or "full",
|
||||
"auth": {
|
||||
"telegram_login_enabled": bool(bot_username),
|
||||
"email_code_login_enabled": bool(MODES_CONFIG.get("WEB_EMAIL_CODE_LOGIN_ENABLED", True)),
|
||||
},
|
||||
"mobile": {
|
||||
"prefer_mini_app_on_telegram_mobile": bool(
|
||||
MODES_CONFIG.get("PREFER_MINI_APP_ON_TELEGRAM_MOBILE", False)
|
||||
),
|
||||
},
|
||||
"features": {
|
||||
"channel_enabled": bool(BUTTONS_CONFIG.get("CHANNEL_BUTTON_ENABLE", CHANNEL_EXISTS)),
|
||||
"donations_enabled": bool(BUTTONS_CONFIG.get("DONATIONS_BUTTON_ENABLE", DONATIONS_ENABLE)),
|
||||
"balance_enabled": bool(BUTTONS_CONFIG.get("BALANCE_BUTTON_ENABLE", BALANCE_BUTTON)),
|
||||
"referral_qr_enabled": bool(BUTTONS_CONFIG.get("REFERRAL_QR_BUTTON_ENABLE", REFERRAL_QR)),
|
||||
"instructions_enabled": bool(BUTTONS_CONFIG.get("INSTRUCTIONS_BUTTON_ENABLE", INSTRUCTIONS_BUTTON)),
|
||||
"gift_enabled": bool(BUTTONS_CONFIG.get("GIFT_BUTTON_ENABLE", GIFT_BUTTON)),
|
||||
"referral_enabled": bool(BUTTONS_CONFIG.get("REFERRAL_BUTTON_ENABLED", REFERRAL_BUTTON)),
|
||||
"top_referral_enabled": bool(BUTTONS_CONFIG.get("TOP_REFERRAL_BUTTON_ENABLE", TOP_REFERRAL_BUTTON)),
|
||||
"coupon_enabled": bool(BUTTONS_CONFIG.get("COUPON_BUTTON_ENABLE", True)),
|
||||
"qr_subscription_enabled": bool(MODES_CONFIG.get("HAPP_CRYPTOLINK_ENABLED", HAPP_CRYPTOLINK)),
|
||||
"hwid_reset_enabled": bool(BUTTONS_CONFIG.get("HWID_RESET_BUTTON_ENABLE", HWID_RESET_BUTTON)),
|
||||
"country_selection_enabled": bool(
|
||||
MODES_CONFIG.get("COUNTRY_SELECTION_ENABLED", USE_COUNTRY_SELECTION)
|
||||
),
|
||||
"captcha_enabled": bool(MODES_CONFIG.get("CAPTCHA_ENABLED", CAPTCHA_ENABLE)),
|
||||
"channel_check_enabled": bool(MODES_CONFIG.get("CHANNEL_CHECK_ENABLED", CHANNEL_REQUIRED)),
|
||||
"trial_enabled": not bool(MODES_CONFIG.get("TRIAL_TIME_DISABLED", TRIAL_TIME_DISABLE)),
|
||||
"mini_app_enabled": bool(MODES_CONFIG.get("REMNAWAVE_WEBAPP_ENABLED", REMNAWAVE_WEBAPP)),
|
||||
"mini_app_open_in_browser": bool(
|
||||
MODES_CONFIG.get("REMNAWAVE_WEBAPP_OPEN_IN_BROWSER", REMNAWAVE_WEBAPP_OPEN_IN_BROWSER)
|
||||
),
|
||||
"partner_enabled": bool(_partner_feature_enabled()),
|
||||
},
|
||||
"payments": {
|
||||
"any_enabled": any_pay,
|
||||
"any_web_link_enabled": bool(web_link_provider_ids),
|
||||
"any_telegram_only_enabled": bool(telegram_only_provider_ids),
|
||||
"web_link_provider_ids": web_link_provider_ids,
|
||||
"telegram_only_provider_ids": telegram_only_provider_ids,
|
||||
"yookassa_enabled": pay_flags.get("YOOKASSA", False),
|
||||
"yoomoney_enabled": pay_flags.get("YOOMONEY", False),
|
||||
"robokassa_enabled": pay_flags.get("ROBOKASSA", False),
|
||||
"kassai_cards_enabled": pay_flags.get("KASSAI_CARDS", False),
|
||||
"kassai_sbp_enabled": pay_flags.get("KASSAI_SBP", False),
|
||||
"tribute_enabled": pay_flags.get("TRIBUTE", False),
|
||||
"heleket_enabled": pay_flags.get("HELEKET", False),
|
||||
"cryptobot_enabled": pay_flags.get("CRYPTOBOT", False),
|
||||
"freekassa_enabled": pay_flags.get("FREEKASSA", False),
|
||||
"stars_enabled": pay_flags.get("STARS", False),
|
||||
},
|
||||
"money": {
|
||||
"currency_mode": currency_mode,
|
||||
"currency_one_screen": currency_one_screen,
|
||||
"cashback_enabled": cashback_percent > 0,
|
||||
"cashback_percent": cashback_percent,
|
||||
},
|
||||
}
|
||||
|
||||
+378
-11
@@ -1,15 +1,62 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from datetime import datetime, timedelta
|
||||
from math import ceil
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from pytz import timezone as tz_moscow
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session
|
||||
from api.depends import get_session, validate_redirect_url, verify_identity_token
|
||||
from api.v2.base_crud import generate_crud_router
|
||||
from api.v2.routes.coupon_pricing import resolve_percent_coupon_pricing
|
||||
from api.v2.schemas import TariffBase, TariffResponse, TariffUpdate
|
||||
from api.v2.schemas.tariffs import TariffGroup, TariffPublic
|
||||
from api.v2.base_crud import generate_crud_router
|
||||
from api.v2.schemas.web_public import (
|
||||
TariffConfigPriceResponse,
|
||||
TariffPurchaseRequest,
|
||||
TariffPurchaseResponse,
|
||||
)
|
||||
from core.bootstrap import PAYMENTS_CONFIG
|
||||
from core.redis_cache import cache_get, cache_key, cache_set
|
||||
from database import (
|
||||
get_balance,
|
||||
identities as idb,
|
||||
)
|
||||
from database.coupons import mark_coupon_used
|
||||
from database.models import Tariff
|
||||
from database.tariffs import get_tariff_by_id
|
||||
from database.temporary_data import create_temporary_data
|
||||
from services.keys import create_vpn_key_headless
|
||||
from logger import logger
|
||||
from services.payments.payment_links import PaymentLinkRequest, create_payment_link
|
||||
from services.payments.providers import WEB_LINK_PROVIDER_IDS
|
||||
from services.tariffs import calculate_config_price
|
||||
|
||||
|
||||
def _tariff_to_public(t: Tariff) -> TariffPublic:
|
||||
dev_opts = getattr(t, "device_options", None)
|
||||
tr_opts = getattr(t, "traffic_options_gb", None)
|
||||
device_options: list[int] | None = None
|
||||
traffic_options_gb: list[int] | None = None
|
||||
if isinstance(dev_opts, list):
|
||||
device_options = []
|
||||
for x in dev_opts:
|
||||
try:
|
||||
device_options.append(int(x))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if not device_options:
|
||||
device_options = None
|
||||
if isinstance(tr_opts, list):
|
||||
traffic_options_gb = []
|
||||
for x in tr_opts:
|
||||
try:
|
||||
traffic_options_gb.append(int(x))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if not traffic_options_gb:
|
||||
traffic_options_gb = None
|
||||
return TariffPublic(
|
||||
id=t.id,
|
||||
name=t.name or "",
|
||||
@@ -21,18 +68,57 @@ def _tariff_to_public(t: Tariff) -> TariffPublic:
|
||||
subgroup_title=t.subgroup_title,
|
||||
sort_order=t.sort_order,
|
||||
vless=bool(getattr(t, "vless", False)),
|
||||
configurable=bool(getattr(t, "configurable", False)),
|
||||
device_options=device_options,
|
||||
traffic_options_gb=traffic_options_gb,
|
||||
)
|
||||
|
||||
|
||||
public_router = APIRouter()
|
||||
|
||||
|
||||
def _resolve_public_base_url(request: Request) -> str:
|
||||
origin = str(request.headers.get("origin") or "").strip()
|
||||
if origin.startswith(("http://", "https://")):
|
||||
return origin.rstrip("/")
|
||||
referer = str(request.headers.get("referer") or request.headers.get("referrer") or "").strip()
|
||||
if referer.startswith(("http://", "https://")):
|
||||
parsed = urlsplit(referer)
|
||||
if parsed.scheme and parsed.netloc:
|
||||
return f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
|
||||
forwarded_host = str(request.headers.get("x-forwarded-host") or "").strip()
|
||||
host = forwarded_host or str(request.headers.get("host") or "").strip()
|
||||
forwarded_proto = str(request.headers.get("x-forwarded-proto") or "").split(",", 1)[0].strip().lower()
|
||||
scheme = forwarded_proto if forwarded_proto in {"http", "https"} else request.url.scheme
|
||||
if host:
|
||||
return f"{scheme}://{host}".rstrip("/")
|
||||
return str(request.base_url).rstrip("/")
|
||||
|
||||
|
||||
def _resolve_default_web_payment_provider() -> str | None:
|
||||
for provider_id in WEB_LINK_PROVIDER_IDS:
|
||||
if bool(PAYMENTS_CONFIG.get(provider_id)):
|
||||
return provider_id
|
||||
return WEB_LINK_PROVIDER_IDS[0] if WEB_LINK_PROVIDER_IDS else None
|
||||
|
||||
|
||||
def _public_tariffs_cache_key(
|
||||
group_code: str | None,
|
||||
tariff_ids: str | None,
|
||||
filter_vless: str | None,
|
||||
) -> str:
|
||||
normalized_group = (group_code or "").strip().lower()
|
||||
normalized_ids = ",".join(part.strip() for part in (tariff_ids or "").split(",") if part.strip())
|
||||
normalized_vless = (filter_vless or "").strip().lower()
|
||||
return cache_key("tariffs_public", normalized_group or "-", normalized_ids or "-", normalized_vless or "-")
|
||||
|
||||
|
||||
@public_router.get("/groups", response_model=list[TariffGroup])
|
||||
async def get_tariff_groups(session: AsyncSession = Depends(get_session)):
|
||||
"""Публичный список групп тарифов — уникальные значения колонки group_code."""
|
||||
q = (
|
||||
select(Tariff.group_code)
|
||||
.where(Tariff.is_active == True, Tariff.group_code.isnot(None), Tariff.group_code != "")
|
||||
.where(Tariff.is_active is True, Tariff.group_code.isnot(None), Tariff.group_code != "")
|
||||
.distinct()
|
||||
.order_by(Tariff.group_code)
|
||||
)
|
||||
@@ -52,23 +138,304 @@ async def get_tariffs_public(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Публичный список активных тарифов (без авторизации)."""
|
||||
q = select(Tariff).where(Tariff.is_active == True).order_by(Tariff.sort_order.asc().nulls_last(), Tariff.price_rub.asc())
|
||||
cache_token = _public_tariffs_cache_key(group_code, tariff_ids, filter_vless)
|
||||
cached = await cache_get(cache_token)
|
||||
if isinstance(cached, list):
|
||||
return cached
|
||||
|
||||
q = select(Tariff).where(Tariff.is_active is True).order_by(Tariff.sort_order.asc().nulls_last(), Tariff.price_rub.asc())
|
||||
if tariff_ids:
|
||||
try:
|
||||
ids = [int(x.strip()) for x in tariff_ids.split(",") if x.strip()]
|
||||
if ids:
|
||||
q = q.where(Tariff.id.in_(ids))
|
||||
if not ids:
|
||||
return []
|
||||
q = q.where(Tariff.id.in_(ids))
|
||||
except ValueError:
|
||||
pass
|
||||
raise HTTPException(status_code=422, detail="Некорректный параметр tariff_ids")
|
||||
elif group_code:
|
||||
q = q.where(Tariff.group_code == group_code)
|
||||
if filter_vless == "router":
|
||||
q = q.where(Tariff.vless == True)
|
||||
q = q.where(Tariff.vless is True)
|
||||
elif filter_vless == "app":
|
||||
q = q.where(Tariff.vless == False)
|
||||
q = q.where(Tariff.vless is False)
|
||||
result = await session.execute(q)
|
||||
rows = result.scalars().all()
|
||||
return [_tariff_to_public(t) for t in rows]
|
||||
payload = [_tariff_to_public(t).model_dump() for t in rows]
|
||||
await cache_set(cache_token, payload, 30)
|
||||
return payload
|
||||
|
||||
|
||||
@public_router.get("/config-price", response_model=TariffConfigPriceResponse)
|
||||
async def get_tariff_config_price(
|
||||
tariff_id: int = Query(..., ge=1),
|
||||
selected_device_limit: int | None = Query(None),
|
||||
selected_traffic_gb: int | None = Query(None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
tariff = await get_tariff_by_id(session, tariff_id)
|
||||
if not tariff or not tariff.get("is_active", True):
|
||||
raise HTTPException(status_code=404, detail="Тариф не найден")
|
||||
price = int(calculate_config_price(tariff, selected_device_limit, selected_traffic_gb))
|
||||
return TariffConfigPriceResponse(price_rub=price)
|
||||
|
||||
|
||||
user_tariff_router = APIRouter()
|
||||
|
||||
|
||||
@user_tariff_router.post("/purchase", response_model=TariffPurchaseResponse)
|
||||
async def purchase_tariff_with_balance(
|
||||
body: TariffPurchaseRequest,
|
||||
request: Request,
|
||||
preview: bool = Query(False),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
tg_id = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
tariff = await get_tariff_by_id(session, body.tariff_id)
|
||||
if not tariff or not tariff.get("is_active", True):
|
||||
raise HTTPException(status_code=404, detail="Тариф не найден")
|
||||
price = int(calculate_config_price(tariff, body.selected_device_limit, body.selected_traffic_gb))
|
||||
if price <= 0:
|
||||
raise HTTPException(status_code=400, detail="Некорректная цена тарифа")
|
||||
final_price, discount_rub, coupon_id, applied_coupon_code = await resolve_percent_coupon_pricing(
|
||||
session=session,
|
||||
billing_user_id=int(tg_id),
|
||||
base_price_rub=int(price),
|
||||
coupon_code=body.coupon_code,
|
||||
)
|
||||
balance = float(await get_balance(session, tg_id))
|
||||
duration = int(tariff.get("duration_days") or 0)
|
||||
if duration <= 0:
|
||||
raise HTTPException(status_code=400, detail="Некорректная длительность тарифа")
|
||||
required_amount = int(max(0, ceil(float(final_price) - balance)))
|
||||
if preview:
|
||||
return TariffPurchaseResponse(
|
||||
ok=True,
|
||||
message="Расчет обновлен",
|
||||
key_email=None,
|
||||
charged_rub=0,
|
||||
base_price_rub=int(price),
|
||||
discount_rub=int(discount_rub),
|
||||
final_price_rub=int(final_price),
|
||||
applied_coupon_code=applied_coupon_code,
|
||||
payment_required=required_amount > 0,
|
||||
required_amount_rub=int(required_amount),
|
||||
payment_id=None,
|
||||
payment_url=None,
|
||||
)
|
||||
if required_amount > 0:
|
||||
provider_id = str(body.provider_id or _resolve_default_web_payment_provider() or "").strip().upper()
|
||||
if not provider_id:
|
||||
raise HTTPException(status_code=503, detail="Нет доступных провайдеров оплаты")
|
||||
base_url = _resolve_public_base_url(request)
|
||||
success_url = validate_redirect_url(str(body.success_url or ""), f"{base_url}/payment-success")
|
||||
failure_url = validate_redirect_url(str(body.failure_url or ""), f"{base_url}/payment-failure")
|
||||
payment_request = PaymentLinkRequest(
|
||||
legacy_user_ref=int(tg_id),
|
||||
amount=required_amount,
|
||||
currency="RUB",
|
||||
provider_id=provider_id,
|
||||
success_url=success_url,
|
||||
failure_url=failure_url,
|
||||
metadata={
|
||||
"payment_flow": "tariff_purchase",
|
||||
"tariff_id": int(body.tariff_id),
|
||||
"selected_device_limit": body.selected_device_limit,
|
||||
"selected_traffic_gb": body.selected_traffic_gb,
|
||||
"selected_duration_days": int(duration),
|
||||
"selected_price_rub": int(final_price),
|
||||
"base_price_rub": int(price),
|
||||
"discount_rub": int(discount_rub),
|
||||
"applied_coupon_code": applied_coupon_code,
|
||||
"coupon_id": int(coupon_id) if coupon_id is not None else None,
|
||||
},
|
||||
)
|
||||
payment_result = await create_payment_link(session, payment_request)
|
||||
if not payment_result.success or not payment_result.payment_url or not payment_result.payment_id:
|
||||
raise HTTPException(status_code=400, detail=payment_result.error or "Не удалось создать ссылку оплаты")
|
||||
await create_temporary_data(
|
||||
session,
|
||||
int(tg_id),
|
||||
"waiting_for_payment",
|
||||
{
|
||||
"tariff_id": int(body.tariff_id),
|
||||
"required_amount": int(required_amount),
|
||||
"selected_price_rub": int(final_price),
|
||||
"selected_device_limit": body.selected_device_limit,
|
||||
"selected_traffic_limit_gb": body.selected_traffic_gb,
|
||||
"selected_duration_days": int(duration),
|
||||
"base_price_rub": int(price),
|
||||
"discount_rub": int(discount_rub),
|
||||
"applied_coupon_code": applied_coupon_code,
|
||||
"coupon_id": int(coupon_id) if coupon_id is not None else None,
|
||||
},
|
||||
)
|
||||
return TariffPurchaseResponse(
|
||||
ok=True,
|
||||
message="Требуется оплата для оформления подписки",
|
||||
key_email=None,
|
||||
charged_rub=0,
|
||||
base_price_rub=int(price),
|
||||
discount_rub=int(discount_rub),
|
||||
final_price_rub=int(final_price),
|
||||
applied_coupon_code=applied_coupon_code,
|
||||
payment_required=True,
|
||||
required_amount_rub=required_amount,
|
||||
payment_id=payment_result.payment_id,
|
||||
payment_url=payment_result.payment_url,
|
||||
)
|
||||
moscow_tz = tz_moscow("Europe/Moscow")
|
||||
expiry = datetime.now(moscow_tz) + timedelta(days=duration)
|
||||
try:
|
||||
await create_vpn_key_headless(
|
||||
session=session,
|
||||
tg_id=tg_id,
|
||||
expiry_time=expiry,
|
||||
plan=body.tariff_id,
|
||||
selected_device_limit=body.selected_device_limit,
|
||||
selected_traffic_gb=body.selected_traffic_gb,
|
||||
selected_price_rub=final_price,
|
||||
)
|
||||
if coupon_id is not None:
|
||||
await mark_coupon_used(session, int(coupon_id), int(tg_id))
|
||||
await session.commit()
|
||||
except Exception:
|
||||
logger.exception("web tariff purchase failed")
|
||||
raise HTTPException(status_code=500, detail="Не удалось оформить подписку") from None
|
||||
return TariffPurchaseResponse(
|
||||
ok=True,
|
||||
message="Подписка оформлена. Ключ в разделе «Мои ключи».",
|
||||
key_email=None,
|
||||
charged_rub=final_price,
|
||||
base_price_rub=int(price),
|
||||
discount_rub=int(discount_rub),
|
||||
final_price_rub=int(final_price),
|
||||
applied_coupon_code=applied_coupon_code,
|
||||
)
|
||||
|
||||
|
||||
@user_tariff_router.post("/trial", response_model=TariffPurchaseResponse)
|
||||
async def activate_trial(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
"""Активация триала (бесплатного или платного). Доступно 1 раз."""
|
||||
from database import get_trial, update_trial
|
||||
from database.tariffs import get_tariffs
|
||||
|
||||
tg_id = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
|
||||
trial_status = await get_trial(session, tg_id)
|
||||
if trial_status not in (0, -1):
|
||||
raise HTTPException(status_code=409, detail="Пробная подписка уже использована")
|
||||
|
||||
trial_tariffs = await get_tariffs(session, group_code="trial")
|
||||
if not trial_tariffs:
|
||||
raise HTTPException(status_code=404, detail="Пробный тариф не найден")
|
||||
|
||||
tariff = trial_tariffs[0]
|
||||
price = int(tariff.get("price_rub", 0) or 0)
|
||||
duration = int(tariff.get("duration_days") or 0)
|
||||
if duration <= 0:
|
||||
raise HTTPException(status_code=400, detail="Некорректная длительность триала")
|
||||
|
||||
if price <= 0:
|
||||
moscow_tz = tz_moscow("Europe/Moscow")
|
||||
expiry = datetime.now(moscow_tz) + timedelta(days=duration)
|
||||
try:
|
||||
await create_vpn_key_headless(
|
||||
session=session,
|
||||
tg_id=tg_id,
|
||||
expiry_time=expiry,
|
||||
plan=int(tariff["id"]),
|
||||
selected_price_rub=0,
|
||||
skip_balance_charge=True,
|
||||
is_trial=True,
|
||||
)
|
||||
await update_trial(session, tg_id, 1)
|
||||
except Exception:
|
||||
logger.exception("web trial activation failed")
|
||||
raise HTTPException(status_code=500, detail="Ошибка активации триала") from None
|
||||
return TariffPurchaseResponse(
|
||||
ok=True,
|
||||
message="Пробная подписка активирована!",
|
||||
charged_rub=0,
|
||||
base_price_rub=0,
|
||||
final_price_rub=0,
|
||||
)
|
||||
|
||||
balance = float(await get_balance(session, tg_id))
|
||||
required_amount = int(max(0, ceil(float(price) - balance)))
|
||||
|
||||
if required_amount <= 0:
|
||||
moscow_tz = tz_moscow("Europe/Moscow")
|
||||
expiry = datetime.now(moscow_tz) + timedelta(days=duration)
|
||||
try:
|
||||
await create_vpn_key_headless(
|
||||
session=session,
|
||||
tg_id=tg_id,
|
||||
expiry_time=expiry,
|
||||
plan=int(tariff["id"]),
|
||||
selected_price_rub=price,
|
||||
is_trial=True,
|
||||
)
|
||||
await update_trial(session, tg_id, 1)
|
||||
except Exception:
|
||||
logger.exception("web paid trial activation failed")
|
||||
raise HTTPException(status_code=500, detail="Ошибка активации триала") from None
|
||||
return TariffPurchaseResponse(
|
||||
ok=True,
|
||||
message="Пробная подписка активирована!",
|
||||
charged_rub=price,
|
||||
base_price_rub=price,
|
||||
final_price_rub=price,
|
||||
)
|
||||
|
||||
provider_id = str(_resolve_default_web_payment_provider() or "").strip().upper()
|
||||
if not provider_id:
|
||||
raise HTTPException(status_code=503, detail="Нет доступных провайдеров оплаты")
|
||||
base_url = _resolve_public_base_url(request)
|
||||
payment_request = PaymentLinkRequest(
|
||||
legacy_user_ref=int(tg_id),
|
||||
amount=required_amount,
|
||||
currency="RUB",
|
||||
provider_id=provider_id,
|
||||
success_url=f"{base_url}/payment-success",
|
||||
failure_url=f"{base_url}/payment-failure",
|
||||
metadata={
|
||||
"payment_flow": "trial_purchase",
|
||||
"tariff_id": int(tariff["id"]),
|
||||
"selected_price_rub": price,
|
||||
"selected_duration_days": duration,
|
||||
},
|
||||
)
|
||||
payment_result = await create_payment_link(session, payment_request)
|
||||
if not payment_result.success or not payment_result.payment_url or not payment_result.payment_id:
|
||||
raise HTTPException(status_code=400, detail=payment_result.error or "Не удалось создать ссылку оплаты")
|
||||
await create_temporary_data(
|
||||
session,
|
||||
int(tg_id),
|
||||
"waiting_for_payment",
|
||||
{
|
||||
"payment_flow": "trial_purchase",
|
||||
"tariff_id": int(tariff["id"]),
|
||||
"required_amount": required_amount,
|
||||
"selected_price_rub": price,
|
||||
"selected_duration_days": duration,
|
||||
},
|
||||
)
|
||||
return TariffPurchaseResponse(
|
||||
ok=True,
|
||||
message="Требуется оплата для активации пробной подписки",
|
||||
charged_rub=0,
|
||||
base_price_rub=price,
|
||||
final_price_rub=price,
|
||||
payment_required=True,
|
||||
required_amount_rub=required_amount,
|
||||
payment_id=payment_result.payment_id,
|
||||
payment_url=payment_result.payment_url,
|
||||
)
|
||||
|
||||
|
||||
router = generate_crud_router(
|
||||
|
||||
@@ -9,7 +9,8 @@ from api.v2.schemas import UserBase, UserResponse, UserUpdate
|
||||
from api.v2.base_crud import generate_crud_router
|
||||
from database import async_session_maker, delete_user_data, get_servers
|
||||
from database.models import Key, User
|
||||
from handlers.keys.operations import delete_key_from_cluster
|
||||
from database.access.resolution import resolve_user_optional
|
||||
from services.operations import delete_key_from_cluster
|
||||
from logger import logger
|
||||
|
||||
router = generate_crud_router(
|
||||
@@ -30,7 +31,10 @@ async def delete_user(
|
||||
):
|
||||
"""Удаляет пользователя и его ключи на серверах."""
|
||||
try:
|
||||
result = await session.execute(select(Key.email, Key.client_id).where(Key.tg_id == tg_id))
|
||||
u = await resolve_user_optional(session, tg_id)
|
||||
if u is None:
|
||||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||||
result = await session.execute(select(Key.email, Key.client_id).where(Key.user_id == u.id))
|
||||
key_records = result.all()
|
||||
|
||||
async with async_session_maker() as s:
|
||||
|
||||
+614
-42
@@ -1,20 +1,72 @@
|
||||
import re
|
||||
import uuid
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, delete
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_identity_admin
|
||||
from api.v2.schemas import WebPageResponse, WebPageUpdate, WebBlockResponse, WebTheme
|
||||
from api.v2.schemas.web import WebUploadResponse
|
||||
from database.models import WebPage, WebBlock, WebTheme as WebThemeModel
|
||||
from api.v2.schemas import WebBlockResponse, WebPageResponse, WebPageUpdate, WebTheme
|
||||
from api.v2.schemas.web import (
|
||||
WebPageVariantCreate,
|
||||
WebPageVariantSummary,
|
||||
WebPageVariantUpdate,
|
||||
WebPageVariantsResponse,
|
||||
WebUploadResponse,
|
||||
)
|
||||
from database.models import (
|
||||
WebBlock,
|
||||
WebCustomElementBuild,
|
||||
WebFlow,
|
||||
WebFlowEvent,
|
||||
WebPage,
|
||||
WebPageVariant,
|
||||
WebPageVariantBlock,
|
||||
WebTheme as WebThemeModel,
|
||||
)
|
||||
from logger import logger
|
||||
|
||||
|
||||
UPLOAD_DIR = Path("static/web_uploads")
|
||||
ALLOWED_EXTENSIONS = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".mp4", ".webm"})
|
||||
MAX_FILE_SIZE = 100 * 1024 * 1024
|
||||
|
||||
_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9\-]*$")
|
||||
|
||||
EXTENSION_CONTENT_TYPES: dict[str, frozenset[str]] = {
|
||||
".png": frozenset({"image/png"}),
|
||||
".jpg": frozenset({"image/jpeg"}),
|
||||
".jpeg": frozenset({"image/jpeg"}),
|
||||
".gif": frozenset({"image/gif"}),
|
||||
".webp": frozenset({"image/webp"}),
|
||||
".svg": frozenset({"image/svg+xml", "text/xml", "application/xml", "text/plain"}),
|
||||
".mp4": frozenset({"video/mp4"}),
|
||||
".webm": frozenset({"video/webm"}),
|
||||
}
|
||||
|
||||
|
||||
def _sanitize_svg(data: bytes) -> bytes:
|
||||
import re as _re
|
||||
text = data.decode("utf-8", errors="replace")
|
||||
text = _re.sub(r"<script[^>]*>.*?</script>", "", text, flags=_re.DOTALL | _re.IGNORECASE)
|
||||
text = _re.sub(r"<style[^>]*>.*?</style>", "", text, flags=_re.DOTALL | _re.IGNORECASE)
|
||||
text = _re.sub(r"\bon\w+\s*=\s*[\"'][^\"']*[\"']", "", text, flags=_re.IGNORECASE)
|
||||
text = _re.sub(r"\bon\w+\s*=\s*\S+", "", text, flags=_re.IGNORECASE)
|
||||
text = _re.sub(r"(?:href|xlink:href)\s*=\s*[\"']\s*javascript:[^\"']*[\"']", "", text, flags=_re.IGNORECASE)
|
||||
text = _re.sub(r"(?:href|xlink:href)\s*=\s*[\"']\s*data:\s*text/html[^\"']*[\"']", "", text, flags=_re.IGNORECASE)
|
||||
text = _re.sub(r"(?:href|xlink:href)\s*=\s*[\"']\s*vbscript:[^\"']*[\"']", "", text, flags=_re.IGNORECASE)
|
||||
text = _re.sub(r"<foreignObject[^>]*>.*?</foreignObject>", "", text, flags=_re.DOTALL | _re.IGNORECASE)
|
||||
text = _re.sub(r"<iframe[^>]*>.*?</iframe>", "", text, flags=_re.DOTALL | _re.IGNORECASE)
|
||||
text = _re.sub(r"<embed[^>]*>", "", text, flags=_re.IGNORECASE)
|
||||
text = _re.sub(r"<object[^>]*>.*?</object>", "", text, flags=_re.DOTALL | _re.IGNORECASE)
|
||||
return text.encode("utf-8")
|
||||
|
||||
|
||||
router = APIRouter(tags=["Web"])
|
||||
|
||||
|
||||
@@ -22,7 +74,47 @@ class WebPagesListResponse(BaseModel):
|
||||
slugs: list[str]
|
||||
|
||||
|
||||
KNOWN_PAGE_SLUGS = ["landing", "tariffs", "faq", "login", "dashboard"]
|
||||
KNOWN_PAGE_SLUGS = [
|
||||
"landing",
|
||||
"tariffs",
|
||||
"faq",
|
||||
"login",
|
||||
"dashboard",
|
||||
"checkout",
|
||||
"gift-entry",
|
||||
"referral-entry",
|
||||
"partner-entry",
|
||||
"payment-success",
|
||||
"payment-failure",
|
||||
"dashboard-keys",
|
||||
"dashboard-profile",
|
||||
"dashboard-instructions",
|
||||
"dashboard-referrals",
|
||||
]
|
||||
|
||||
DEFAULT_VARIANT_KEY = "default"
|
||||
DEFAULT_VARIANT_NAME = "Основной"
|
||||
|
||||
|
||||
def _normalize_variant_key(value: str | None) -> str:
|
||||
raw = (value or "").strip().lower()
|
||||
normalized = re.sub(r"[^a-z0-9]+", "-", raw).strip("-")
|
||||
if not normalized:
|
||||
return DEFAULT_VARIANT_KEY
|
||||
return normalized[:64].strip("-") or DEFAULT_VARIANT_KEY
|
||||
|
||||
|
||||
def _normalize_variant_name(value: str | None, fallback: str) -> str:
|
||||
name = (value or "").strip()
|
||||
return name[:255] if name else fallback
|
||||
|
||||
|
||||
def _variant_summary(row: WebPageVariant) -> WebPageVariantSummary:
|
||||
return WebPageVariantSummary(
|
||||
key=row.variant_key,
|
||||
name=row.name or row.variant_key,
|
||||
is_active=bool(row.is_active),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/web/pages", response_model=WebPagesListResponse)
|
||||
@@ -46,69 +138,296 @@ async def get_or_create_page(session: AsyncSession, slug: str) -> WebPage:
|
||||
return page
|
||||
|
||||
|
||||
@router.get("/api/web/pages/{slug}", response_model=WebPageResponse)
|
||||
async def get_web_page(
|
||||
slug: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
await get_or_create_page(session, slug)
|
||||
async def _list_variants(session: AsyncSession, slug: str) -> list[WebPageVariant]:
|
||||
result = await session.execute(
|
||||
select(WebPageVariant)
|
||||
.where(WebPageVariant.page_slug == slug)
|
||||
.order_by(WebPageVariant.is_active.desc(), WebPageVariant.created_at, WebPageVariant.variant_key)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def _get_theme_tokens_for_legacy_page(session: AsyncSession, slug: str) -> dict:
|
||||
theme_result = await session.execute(select(WebThemeModel).where(WebThemeModel.page_slug == slug))
|
||||
theme_row = theme_result.scalar_one_or_none()
|
||||
return dict(theme_row.tokens or {}) if theme_row else {}
|
||||
|
||||
|
||||
async def _get_legacy_blocks(session: AsyncSession, slug: str) -> list[WebBlock]:
|
||||
blocks_result = await session.execute(
|
||||
select(WebBlock).where(WebBlock.page_slug == slug).order_by(WebBlock.order, WebBlock.id)
|
||||
)
|
||||
blocks = [WebBlockResponse.model_validate(b) for b in blocks_result.scalars().all()]
|
||||
return list(blocks_result.scalars().all())
|
||||
|
||||
theme_result = await session.execute(select(WebThemeModel).where(WebThemeModel.page_slug == slug))
|
||||
theme_row = theme_result.scalar_one_or_none()
|
||||
theme = WebTheme(tokens=theme_row.tokens) if theme_row else None
|
||||
|
||||
return WebPageResponse(slug=slug, blocks=blocks, theme=theme)
|
||||
async def _ensure_page_variants(session: AsyncSession, slug: str) -> list[WebPageVariant]:
|
||||
await get_or_create_page(session, slug)
|
||||
variants = await _list_variants(session, slug)
|
||||
if variants:
|
||||
if not any(variant.is_active for variant in variants):
|
||||
variants[0].is_active = True
|
||||
await session.flush()
|
||||
variants = await _list_variants(session, slug)
|
||||
return variants
|
||||
|
||||
legacy_blocks = await _get_legacy_blocks(session, slug)
|
||||
theme_tokens = await _get_theme_tokens_for_legacy_page(session, slug)
|
||||
variant = WebPageVariant(
|
||||
page_slug=slug,
|
||||
variant_key=DEFAULT_VARIANT_KEY,
|
||||
name=DEFAULT_VARIANT_NAME,
|
||||
is_active=True,
|
||||
theme_tokens=theme_tokens,
|
||||
)
|
||||
session.add(variant)
|
||||
await session.flush()
|
||||
for legacy_block in legacy_blocks:
|
||||
session.add(
|
||||
WebPageVariantBlock(
|
||||
variant_id=variant.id,
|
||||
order=legacy_block.order,
|
||||
type=legacy_block.type,
|
||||
data=legacy_block.data,
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
return await _list_variants(session, slug)
|
||||
|
||||
|
||||
async def _resolve_variant(
|
||||
session: AsyncSession,
|
||||
slug: str,
|
||||
variant_key: str | None,
|
||||
) -> tuple[WebPageVariant, list[WebPageVariant]]:
|
||||
variants = await _ensure_page_variants(session, slug)
|
||||
desired_key = _normalize_variant_key(variant_key) if variant_key else ""
|
||||
current = None
|
||||
if desired_key:
|
||||
current = next((variant for variant in variants if variant.variant_key == desired_key), None)
|
||||
if current is None:
|
||||
raise HTTPException(404, "Вариант страницы не найден")
|
||||
else:
|
||||
current = next((variant for variant in variants if variant.is_active), variants[0])
|
||||
return current, variants
|
||||
|
||||
|
||||
async def _get_variant_blocks(session: AsyncSession, variant_id: str) -> list[WebBlockResponse]:
|
||||
blocks_result = await session.execute(
|
||||
select(WebPageVariantBlock)
|
||||
.where(WebPageVariantBlock.variant_id == variant_id)
|
||||
.order_by(WebPageVariantBlock.order, WebPageVariantBlock.id)
|
||||
)
|
||||
return [WebBlockResponse.model_validate(block) for block in blocks_result.scalars().all()]
|
||||
|
||||
|
||||
async def _build_page_response(
|
||||
session: AsyncSession,
|
||||
slug: str,
|
||||
current: WebPageVariant,
|
||||
variants: list[WebPageVariant] | None = None,
|
||||
) -> WebPageResponse:
|
||||
current_variants = variants or await _list_variants(session, slug)
|
||||
active = next((variant for variant in current_variants if variant.is_active), current)
|
||||
blocks = await _get_variant_blocks(session, current.id)
|
||||
theme = WebTheme(tokens=dict(current.theme_tokens or {}))
|
||||
return WebPageResponse(
|
||||
slug=slug,
|
||||
blocks=blocks,
|
||||
theme=theme,
|
||||
variant_key=current.variant_key,
|
||||
active_variant_key=active.variant_key,
|
||||
variants=[_variant_summary(variant) for variant in current_variants],
|
||||
)
|
||||
|
||||
|
||||
async def _set_active_variant(session: AsyncSession, slug: str, variant_key: str) -> list[WebPageVariant]:
|
||||
variants = await _list_variants(session, slug)
|
||||
matched = False
|
||||
for variant in variants:
|
||||
is_target = variant.variant_key == variant_key
|
||||
variant.is_active = is_target
|
||||
matched = matched or is_target
|
||||
if not matched:
|
||||
raise HTTPException(404, "Вариант страницы не найден")
|
||||
await session.flush()
|
||||
return await _list_variants(session, slug)
|
||||
|
||||
|
||||
def _generate_variant_key(existing_keys: set[str], requested_key: str | None, requested_name: str | None) -> str:
|
||||
base = _normalize_variant_key(requested_key or requested_name)
|
||||
if not base:
|
||||
base = DEFAULT_VARIANT_KEY
|
||||
if base not in existing_keys:
|
||||
return base
|
||||
suffix = 2
|
||||
while True:
|
||||
candidate = f"{base}-{suffix}"
|
||||
if candidate not in existing_keys:
|
||||
return candidate[:64]
|
||||
suffix += 1
|
||||
|
||||
|
||||
@router.get("/api/web/pages/{slug}", response_model=WebPageResponse)
|
||||
async def get_web_page(
|
||||
slug: str,
|
||||
variant: str | None = Query(default=None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
if not slug or len(slug) > 64 or not _SLUG_RE.match(slug):
|
||||
raise HTTPException(400, "Некорректный slug страницы")
|
||||
current, variants = await _resolve_variant(session, slug, variant)
|
||||
return await _build_page_response(session, slug, current, variants)
|
||||
|
||||
|
||||
@router.put("/api/web/pages/{slug}", response_model=WebPageResponse)
|
||||
async def update_web_page(
|
||||
slug: str,
|
||||
body: WebPageUpdate,
|
||||
variant: str | None = Query(default=None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_admin),
|
||||
):
|
||||
await get_or_create_page(session, slug)
|
||||
|
||||
await session.execute(delete(WebBlock).where(WebBlock.page_slug == slug))
|
||||
current, _ = await _resolve_variant(session, slug, variant)
|
||||
await session.execute(delete(WebPageVariantBlock).where(WebPageVariantBlock.variant_id == current.id))
|
||||
|
||||
for block in body.blocks:
|
||||
session.add(
|
||||
WebBlock(
|
||||
page_slug=slug,
|
||||
WebPageVariantBlock(
|
||||
variant_id=current.id,
|
||||
order=block.order,
|
||||
type=block.type,
|
||||
data=block.data,
|
||||
)
|
||||
)
|
||||
|
||||
theme_row = None
|
||||
if body.theme is not None:
|
||||
result = await session.execute(select(WebThemeModel).where(WebThemeModel.page_slug == slug))
|
||||
theme_row = result.scalar_one_or_none()
|
||||
if theme_row is None:
|
||||
theme_row = WebThemeModel(page_slug=slug, tokens=body.theme.tokens)
|
||||
session.add(theme_row)
|
||||
else:
|
||||
theme_row.tokens = body.theme.tokens
|
||||
current.theme_tokens = body.theme.tokens
|
||||
|
||||
await session.flush()
|
||||
refreshed_variants = await _list_variants(session, slug)
|
||||
refreshed_current = next((item for item in refreshed_variants if item.id == current.id), current)
|
||||
return await _build_page_response(session, slug, refreshed_current, refreshed_variants)
|
||||
|
||||
blocks_result = await session.execute(
|
||||
select(WebBlock).where(WebBlock.page_slug == slug).order_by(WebBlock.order, WebBlock.id)
|
||||
|
||||
@router.get("/api/web/pages/{slug}/variants", response_model=WebPageVariantsResponse)
|
||||
async def get_web_page_variants(
|
||||
slug: str,
|
||||
variant: str | None = Query(default=None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
current, variants = await _resolve_variant(session, slug, variant)
|
||||
active = next((item for item in variants if item.is_active), current)
|
||||
return WebPageVariantsResponse(
|
||||
slug=slug,
|
||||
active_variant_key=active.variant_key,
|
||||
current_variant_key=current.variant_key,
|
||||
variants=[_variant_summary(item) for item in variants],
|
||||
)
|
||||
blocks = [WebBlockResponse.model_validate(b) for b in blocks_result.scalars().all()]
|
||||
|
||||
if theme_row is None:
|
||||
theme_result = await session.execute(select(WebThemeModel).where(WebThemeModel.page_slug == slug))
|
||||
theme_row = theme_result.scalar_one_or_none()
|
||||
theme = WebTheme(tokens=theme_row.tokens) if theme_row else None
|
||||
|
||||
return WebPageResponse(slug=slug, blocks=blocks, theme=theme)
|
||||
@router.post("/api/web/pages/{slug}/variants", response_model=WebPageVariantsResponse)
|
||||
async def create_web_page_variant(
|
||||
slug: str,
|
||||
body: WebPageVariantCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_admin),
|
||||
):
|
||||
source_variant, variants = await _resolve_variant(session, slug, body.from_variant_key)
|
||||
existing_keys = {variant.variant_key for variant in variants}
|
||||
variant_key = _generate_variant_key(existing_keys, body.key, body.name)
|
||||
if variant_key in existing_keys:
|
||||
raise HTTPException(400, "Вариант с таким ключом уже существует")
|
||||
|
||||
variant_name = _normalize_variant_name(body.name, f"Вариант {len(variants) + 1}")
|
||||
new_variant = WebPageVariant(
|
||||
page_slug=slug,
|
||||
variant_key=variant_key,
|
||||
name=variant_name,
|
||||
is_active=False,
|
||||
theme_tokens=dict(source_variant.theme_tokens or {}),
|
||||
)
|
||||
session.add(new_variant)
|
||||
await session.flush()
|
||||
|
||||
source_blocks = await _get_variant_blocks(session, source_variant.id)
|
||||
for block in source_blocks:
|
||||
session.add(
|
||||
WebPageVariantBlock(
|
||||
variant_id=new_variant.id,
|
||||
order=block.order,
|
||||
type=block.type,
|
||||
data=block.data,
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
|
||||
refreshed = await _list_variants(session, slug)
|
||||
return WebPageVariantsResponse(
|
||||
slug=slug,
|
||||
active_variant_key=next((item.variant_key for item in refreshed if item.is_active), DEFAULT_VARIANT_KEY),
|
||||
current_variant_key=new_variant.variant_key,
|
||||
variants=[_variant_summary(item) for item in refreshed],
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/api/web/pages/{slug}/variants/{variant_key}", response_model=WebPageVariantsResponse)
|
||||
async def update_web_page_variant(
|
||||
slug: str,
|
||||
variant_key: str,
|
||||
body: WebPageVariantUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_admin),
|
||||
):
|
||||
current, variants = await _resolve_variant(session, slug, variant_key)
|
||||
if body.name is not None:
|
||||
current.name = _normalize_variant_name(body.name, current.name or current.variant_key)
|
||||
if body.make_active is True:
|
||||
variants = await _set_active_variant(session, slug, current.variant_key)
|
||||
current = next((item for item in variants if item.variant_key == current.variant_key), current)
|
||||
else:
|
||||
await session.flush()
|
||||
variants = await _list_variants(session, slug)
|
||||
|
||||
active = next((item for item in variants if item.is_active), current)
|
||||
return WebPageVariantsResponse(
|
||||
slug=slug,
|
||||
active_variant_key=active.variant_key,
|
||||
current_variant_key=current.variant_key,
|
||||
variants=[_variant_summary(item) for item in variants],
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/api/web/pages/{slug}/variants/{variant_key}", response_model=WebPageVariantsResponse)
|
||||
async def delete_web_page_variant(
|
||||
slug: str,
|
||||
variant_key: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_admin),
|
||||
):
|
||||
current, variants = await _resolve_variant(session, slug, variant_key)
|
||||
if len(variants) <= 1:
|
||||
raise HTTPException(400, "Нельзя удалить единственный вариант страницы")
|
||||
|
||||
replacement = next((item for item in variants if item.variant_key != current.variant_key), None)
|
||||
await session.execute(delete(WebPageVariant).where(WebPageVariant.id == current.id))
|
||||
await session.flush()
|
||||
|
||||
if current.is_active and replacement is not None:
|
||||
replacement_variants = await _set_active_variant(session, slug, replacement.variant_key)
|
||||
else:
|
||||
replacement_variants = await _list_variants(session, slug)
|
||||
|
||||
current_variant_key = replacement.variant_key if replacement is not None else DEFAULT_VARIANT_KEY
|
||||
active_variant_key = next(
|
||||
(item.variant_key for item in replacement_variants if item.is_active),
|
||||
current_variant_key,
|
||||
)
|
||||
return WebPageVariantsResponse(
|
||||
slug=slug,
|
||||
active_variant_key=active_variant_key,
|
||||
current_variant_key=current_variant_key,
|
||||
variants=[_variant_summary(item) for item in replacement_variants],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/web/upload", response_model=WebUploadResponse)
|
||||
@@ -125,18 +444,271 @@ async def upload_media(
|
||||
400,
|
||||
f"Разрешены только: {', '.join(sorted(ALLOWED_EXTENSIONS))}",
|
||||
)
|
||||
if file.content_type:
|
||||
allowed_types = EXTENSION_CONTENT_TYPES.get(ext)
|
||||
if allowed_types and file.content_type.lower() not in allowed_types:
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"Тип файла ({file.content_type}) не соответствует расширению ({ext})",
|
||||
)
|
||||
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
chunks: list[bytes] = []
|
||||
size = 0
|
||||
for chunk in file.file:
|
||||
size += len(chunk)
|
||||
if size > MAX_FILE_SIZE:
|
||||
raise HTTPException(400, f"Размер файла не более {MAX_FILE_SIZE // (1024*1024)} МБ")
|
||||
await file.seek(0)
|
||||
raise HTTPException(400, f"Размер файла не более {MAX_FILE_SIZE // (1024 * 1024)} МБ")
|
||||
chunks.append(chunk)
|
||||
name = f"{uuid.uuid4().hex}{ext}"
|
||||
path = UPLOAD_DIR / name
|
||||
file_data = b"".join(chunks)
|
||||
if ext == ".svg":
|
||||
file_data = _sanitize_svg(file_data)
|
||||
with open(path, "wb") as f:
|
||||
while chunk := await file.read(64 * 1024):
|
||||
f.write(chunk)
|
||||
f.write(file_data)
|
||||
url = f"/api/web/uploads/{name}"
|
||||
logger.info(
|
||||
"[WebUpload] admin={} file={} -> {} ({} bytes)",
|
||||
identity.id,
|
||||
file.filename,
|
||||
name,
|
||||
len(file_data),
|
||||
)
|
||||
return WebUploadResponse(url=url)
|
||||
|
||||
|
||||
# ── Custom Element Builds ──
|
||||
|
||||
|
||||
class CustomElementBuildCreate(BaseModel):
|
||||
label: str = ""
|
||||
slug: str = ""
|
||||
runtime: str = "react-component"
|
||||
source_kind: str = "inline-code"
|
||||
source_value: str = ""
|
||||
export_name: str = "default"
|
||||
props_schema_text: str = ""
|
||||
sample_props_text: str = ""
|
||||
events_text: str = ""
|
||||
notes: str = ""
|
||||
|
||||
|
||||
class CustomElementBuildUpdate(BaseModel):
|
||||
status: str | None = None
|
||||
summary: str | None = None
|
||||
next_steps: list[str] | None = None
|
||||
artifact: dict | None = None
|
||||
upload_meta: dict | None = None
|
||||
worker_id: str | None = None
|
||||
|
||||
|
||||
def _build_to_dict(b: WebCustomElementBuild) -> dict:
|
||||
return {
|
||||
"id": b.id,
|
||||
"label": b.label,
|
||||
"slug": b.slug,
|
||||
"runtime": b.runtime,
|
||||
"sourceKind": b.source_kind,
|
||||
"sourceValue": b.source_value,
|
||||
"exportName": b.export_name,
|
||||
"propsSchemaText": b.props_schema_text,
|
||||
"samplePropsText": b.sample_props_text,
|
||||
"eventsText": b.events_text,
|
||||
"notes": b.notes,
|
||||
"status": b.status,
|
||||
"summary": b.summary,
|
||||
"nextSteps": b.next_steps or [],
|
||||
"artifact": b.artifact,
|
||||
"upload": b.upload_meta,
|
||||
"workerId": b.worker_id,
|
||||
"workerClaimedAt": b.worker_claimed_at.isoformat() if b.worker_claimed_at else None,
|
||||
"completedAt": b.completed_at.isoformat() if b.completed_at else None,
|
||||
"createdAt": b.created_at.isoformat() if b.created_at else None,
|
||||
"updatedAt": b.updated_at.isoformat() if b.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/custom-element-builds")
|
||||
async def list_custom_element_builds(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_identity=Depends(verify_identity_admin),
|
||||
):
|
||||
result = await session.execute(
|
||||
select(WebCustomElementBuild).order_by(WebCustomElementBuild.created_at.desc())
|
||||
)
|
||||
builds = result.scalars().all()
|
||||
return [_build_to_dict(b) for b in builds]
|
||||
|
||||
|
||||
@router.post("/custom-element-builds")
|
||||
async def create_custom_element_build(
|
||||
body: CustomElementBuildCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_identity=Depends(verify_identity_admin),
|
||||
):
|
||||
build = WebCustomElementBuild(
|
||||
id=str(uuid.uuid4()),
|
||||
label=body.label,
|
||||
slug=body.slug,
|
||||
runtime=body.runtime,
|
||||
source_kind=body.source_kind,
|
||||
source_value=body.source_value,
|
||||
export_name=body.export_name,
|
||||
props_schema_text=body.props_schema_text,
|
||||
sample_props_text=body.sample_props_text,
|
||||
events_text=body.events_text,
|
||||
notes=body.notes,
|
||||
status="queued",
|
||||
)
|
||||
session.add(build)
|
||||
return _build_to_dict(build)
|
||||
|
||||
|
||||
@router.get("/custom-element-builds/{build_id}")
|
||||
async def get_custom_element_build(
|
||||
build_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_identity=Depends(verify_identity_admin),
|
||||
):
|
||||
build = await session.get(WebCustomElementBuild, build_id)
|
||||
if not build:
|
||||
raise HTTPException(404, "Build not found")
|
||||
return _build_to_dict(build)
|
||||
|
||||
|
||||
@router.patch("/custom-element-builds/{build_id}")
|
||||
async def update_custom_element_build(
|
||||
build_id: str,
|
||||
body: CustomElementBuildUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_identity=Depends(verify_identity_admin),
|
||||
):
|
||||
build = await session.get(WebCustomElementBuild, build_id)
|
||||
if not build:
|
||||
raise HTTPException(404, "Build not found")
|
||||
if body.status is not None:
|
||||
build.status = body.status
|
||||
if body.summary is not None:
|
||||
build.summary = body.summary
|
||||
if body.next_steps is not None:
|
||||
build.next_steps = body.next_steps
|
||||
if body.artifact is not None:
|
||||
build.artifact = body.artifact
|
||||
if body.upload_meta is not None:
|
||||
build.upload_meta = body.upload_meta
|
||||
if body.worker_id is not None:
|
||||
build.worker_id = body.worker_id
|
||||
return _build_to_dict(build)
|
||||
|
||||
|
||||
@router.delete("/custom-element-builds/{build_id}")
|
||||
async def delete_custom_element_build(
|
||||
build_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_identity=Depends(verify_identity_admin),
|
||||
):
|
||||
build = await session.get(WebCustomElementBuild, build_id)
|
||||
if not build:
|
||||
raise HTTPException(404, "Build not found")
|
||||
await session.delete(build)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Flow Analytics ──
|
||||
|
||||
|
||||
class FlowEventBatch(BaseModel):
|
||||
events: list[dict]
|
||||
|
||||
|
||||
@router.post("/analytics/flow-events")
|
||||
async def ingest_flow_events(
|
||||
body: FlowEventBatch,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
from core.redis_cache import cache_incr_checked
|
||||
from api.v2.routes.auth._fallback_limiter import check_and_increment
|
||||
ip = (request.client.host if request.client else "") or "unknown"
|
||||
count, redis_ok = await cache_incr_checked(f"analytics_rate:{ip}", 60)
|
||||
if not redis_ok:
|
||||
count = check_and_increment(f"analytics_rate:{ip}", 60, 60)
|
||||
if count > 60:
|
||||
raise HTTPException(status_code=429, detail="Too many events")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
pass
|
||||
created = 0
|
||||
for raw in body.events[:100]:
|
||||
flow_id = str(raw.get("flowId", ""))
|
||||
node_id = str(raw.get("nodeId", ""))
|
||||
event_type = str(raw.get("eventType", ""))
|
||||
if not flow_id or not node_id or not event_type:
|
||||
continue
|
||||
ev = WebFlowEvent(
|
||||
id=str(uuid.uuid4()),
|
||||
flow_id=flow_id,
|
||||
node_id=node_id,
|
||||
node_type=str(raw.get("nodeType", "")),
|
||||
event_type=event_type,
|
||||
ab_variant=raw.get("abVariant") or None,
|
||||
device=raw.get("device") or None,
|
||||
locale=raw.get("locale") or None,
|
||||
authenticated=raw.get("authenticated"),
|
||||
event_metadata=raw.get("collectedDataSnapshot") or None,
|
||||
)
|
||||
session.add(ev)
|
||||
created += 1
|
||||
return {"ingested": created}
|
||||
|
||||
|
||||
@router.get("/analytics/flow-funnel/{flow_id}")
|
||||
async def get_flow_funnel(
|
||||
flow_id: str,
|
||||
days: int = Query(default=30, ge=1, le=365),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_identity=Depends(verify_identity_admin),
|
||||
):
|
||||
since = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(
|
||||
WebFlowEvent.node_id,
|
||||
WebFlowEvent.node_type,
|
||||
WebFlowEvent.event_type,
|
||||
func.count().label("cnt"),
|
||||
)
|
||||
.where(WebFlowEvent.flow_id == flow_id)
|
||||
.where(WebFlowEvent.created_at >= since)
|
||||
.group_by(WebFlowEvent.node_id, WebFlowEvent.node_type, WebFlowEvent.event_type)
|
||||
)
|
||||
).all()
|
||||
|
||||
nodes: dict[str, dict] = {}
|
||||
for node_id, node_type, event_type, cnt in rows:
|
||||
if node_id not in nodes:
|
||||
nodes[node_id] = {"nodeId": node_id, "nodeType": node_type, "entered": 0, "exited": 0, "completed": 0}
|
||||
if event_type == "flow_step_entered":
|
||||
nodes[node_id]["entered"] = cnt
|
||||
elif event_type == "flow_step_exited":
|
||||
nodes[node_id]["exited"] = cnt
|
||||
elif event_type == "flow_completed":
|
||||
nodes[node_id]["completed"] = cnt
|
||||
|
||||
flow = await session.get(WebFlow, flow_id)
|
||||
if flow and flow.nodes:
|
||||
node_order = {n["id"]: i for i, n in enumerate(flow.nodes) if isinstance(n, dict)}
|
||||
else:
|
||||
node_order = {}
|
||||
|
||||
funnel = sorted(nodes.values(), key=lambda n: node_order.get(n["nodeId"], 999))
|
||||
|
||||
for i, node in enumerate(funnel):
|
||||
prev_entered = funnel[i - 1]["entered"] if i > 0 else node["entered"]
|
||||
node["dropOff"] = round(
|
||||
(1 - node["entered"] / prev_entered) * 100, 1
|
||||
) if prev_entered > 0 else 0
|
||||
|
||||
return {"flowId": flow_id, "days": days, "funnel": funnel}
|
||||
|
||||
@@ -28,4 +28,13 @@ from api.v1.schemas import (
|
||||
)
|
||||
from api.v1.schemas.keys import KeyBase, KeyCreateRequest, KeyUpdate
|
||||
from api.v1.schemas.settings import SettingResponse, SettingUpsert
|
||||
from api.v2.schemas.web import WebBlockResponse, WebTheme, WebPageResponse, WebPageUpdate
|
||||
from api.v2.schemas.web import (
|
||||
WebBlockResponse,
|
||||
WebTheme,
|
||||
WebPageResponse,
|
||||
WebPageUpdate,
|
||||
WebPageVariantCreate,
|
||||
WebPageVariantSummary,
|
||||
WebPageVariantUpdate,
|
||||
WebPageVariantsResponse,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class EdgeConditionSchema(BaseModel):
|
||||
field: str
|
||||
operator: str
|
||||
value: Any = None
|
||||
|
||||
|
||||
class FlowEdgeSchema(BaseModel):
|
||||
id: str
|
||||
source: str
|
||||
target: str
|
||||
condition: EdgeConditionSchema | None = None
|
||||
label: str | None = None
|
||||
priority: int | None = None
|
||||
|
||||
|
||||
class FlowNodeSchema(BaseModel):
|
||||
id: str
|
||||
type: str
|
||||
label: str
|
||||
label_en: str | None = None
|
||||
enabled: bool = True
|
||||
page_slug: str | None = None
|
||||
config: dict = {}
|
||||
position: dict
|
||||
|
||||
|
||||
class FlowResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
nodes: list[FlowNodeSchema]
|
||||
edges: list[FlowEdgeSchema]
|
||||
entry_node_id: str | None
|
||||
version: int
|
||||
|
||||
|
||||
class FlowUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
nodes: list[FlowNodeSchema]
|
||||
edges: list[FlowEdgeSchema]
|
||||
entry_node_id: str | None = None
|
||||
|
||||
|
||||
class FlowCreate(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
nodes: list[FlowNodeSchema] = []
|
||||
edges: list[FlowEdgeSchema] = []
|
||||
entry_node_id: str | None = None
|
||||
@@ -13,6 +13,8 @@ class IdentityResponse(BaseModel):
|
||||
email: str | None
|
||||
tg_id: int | None
|
||||
is_admin: bool = False
|
||||
email_verified: bool = False
|
||||
password_set: bool = False
|
||||
created_at: datetime | None
|
||||
updated_at: datetime | None
|
||||
|
||||
@@ -23,11 +25,12 @@ class IdentityResponse(BaseModel):
|
||||
class RegisterByEmailRequest(BaseModel):
|
||||
email: str = Field(..., min_length=1)
|
||||
password: str = Field(..., min_length=8, description="Пароль (минимум 8 символов)")
|
||||
referral_code: str | None = Field(None, min_length=1)
|
||||
turnstile_token: str | None = Field(default=None, description="Cloudflare Turnstile CAPTCHA token")
|
||||
|
||||
|
||||
class RegisterResponse(BaseModel):
|
||||
identity_id: str
|
||||
token: str
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
@@ -35,13 +38,28 @@ class LoginRequest(BaseModel):
|
||||
password: str = Field(...)
|
||||
|
||||
|
||||
class SetPasswordRequest(BaseModel):
|
||||
password: str = Field(..., min_length=8, description="Новый пароль (минимум 8 символов)")
|
||||
password_confirm: str = Field(..., min_length=8)
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
current_password: str = Field(...)
|
||||
password: str = Field(..., min_length=8, description="Новый пароль (минимум 8 символов)")
|
||||
password_confirm: str = Field(..., min_length=8)
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
identity_id: str
|
||||
token: str
|
||||
|
||||
|
||||
class SendLoginCodeRequest(BaseModel):
|
||||
email: str = Field(..., min_length=1)
|
||||
allow_register: bool = Field(
|
||||
default=False,
|
||||
description="Если true и email новый — создать идентичность и отправить код (гостевой вход с сайта)",
|
||||
)
|
||||
turnstile_token: str | None = Field(default=None, description="Cloudflare Turnstile CAPTCHA token")
|
||||
|
||||
|
||||
class LoginByCodeRequest(BaseModel):
|
||||
@@ -49,6 +67,13 @@ class LoginByCodeRequest(BaseModel):
|
||||
code: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class ConfirmPasswordResetRequest(BaseModel):
|
||||
email: str = Field(..., min_length=1)
|
||||
code: str = Field(..., min_length=1)
|
||||
password: str = Field(..., min_length=8)
|
||||
password_confirm: str = Field(..., min_length=8)
|
||||
|
||||
|
||||
class LoginTelegramRequest(BaseModel):
|
||||
"""Данные от Telegram Login Widget (кнопка «Войти через Telegram»)."""
|
||||
|
||||
@@ -77,5 +102,14 @@ class IdentityAttachEmail(BaseModel):
|
||||
email: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class LinkEmailSendCodeRequest(BaseModel):
|
||||
email: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class LinkEmailConfirmRequest(BaseModel):
|
||||
email: str = Field(..., min_length=1)
|
||||
code: str = Field(..., min_length=1, max_length=16)
|
||||
|
||||
|
||||
class IdentityAttachTelegram(BaseModel):
|
||||
tg_id: int = Field(...)
|
||||
|
||||
@@ -22,3 +22,11 @@ class PaymentLinkCreateResponse(BaseModel):
|
||||
payment_id: str | None = None
|
||||
payment_url: str | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class PaymentLinkStatusResponse(BaseModel):
|
||||
success: bool
|
||||
payment_id: str
|
||||
status: str | None = None
|
||||
completed: bool = False
|
||||
paid: bool = False
|
||||
|
||||
@@ -17,4 +17,7 @@ class TariffPublic(BaseModel):
|
||||
device_limit: int | None
|
||||
subgroup_title: str | None
|
||||
sort_order: int | None
|
||||
vless: bool = False
|
||||
vless: bool = False
|
||||
configurable: bool = False
|
||||
device_options: list[int] | None = None
|
||||
traffic_options_gb: list[int] | None = None
|
||||
|
||||
+71
-1
@@ -1,6 +1,9 @@
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
_MAX_BLOCK_DATA_SIZE = 256 * 1024
|
||||
|
||||
|
||||
class WebBlockBase(BaseModel):
|
||||
@@ -8,6 +11,12 @@ class WebBlockBase(BaseModel):
|
||||
order: int
|
||||
data: dict[str, Any]
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_data_size(self) -> "WebBlockBase":
|
||||
if len(json.dumps(self.data, ensure_ascii=False)) > _MAX_BLOCK_DATA_SIZE:
|
||||
raise ValueError(f"Размер data блока не должен превышать {_MAX_BLOCK_DATA_SIZE // 1024} КБ")
|
||||
return self
|
||||
|
||||
|
||||
class WebBlockResponse(WebBlockBase):
|
||||
id: str
|
||||
@@ -20,10 +29,19 @@ class WebTheme(BaseModel):
|
||||
tokens: dict[str, Any]
|
||||
|
||||
|
||||
class WebPageVariantSummary(BaseModel):
|
||||
key: str = Field(..., max_length=64)
|
||||
name: str = Field(..., max_length=255)
|
||||
is_active: bool = False
|
||||
|
||||
|
||||
class WebPageResponse(BaseModel):
|
||||
slug: str
|
||||
blocks: list[WebBlockResponse]
|
||||
theme: WebTheme | None = None
|
||||
variant_key: str = "default"
|
||||
active_variant_key: str = "default"
|
||||
variants: list[WebPageVariantSummary] = Field(default_factory=list)
|
||||
|
||||
|
||||
class WebPageUpdate(BaseModel):
|
||||
@@ -31,6 +49,58 @@ class WebPageUpdate(BaseModel):
|
||||
theme: WebTheme | None = None
|
||||
|
||||
|
||||
class WebPageVariantCreate(BaseModel):
|
||||
key: str | None = Field(default=None, max_length=64)
|
||||
name: str | None = Field(default=None, max_length=255)
|
||||
from_variant_key: str | None = Field(default=None, max_length=64)
|
||||
|
||||
|
||||
class WebPageVariantUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, max_length=255)
|
||||
make_active: bool | None = None
|
||||
|
||||
|
||||
class WebPageVariantsResponse(BaseModel):
|
||||
slug: str
|
||||
active_variant_key: str = "default"
|
||||
current_variant_key: str = "default"
|
||||
variants: list[WebPageVariantSummary] = Field(default_factory=list)
|
||||
|
||||
|
||||
class WebUploadResponse(BaseModel):
|
||||
url: str
|
||||
|
||||
|
||||
class FlowStepConfig(BaseModel):
|
||||
provider_ids: list[str] | None = None
|
||||
tariff_group_code: str | None = None
|
||||
tariff_ids: list[int] | None = None
|
||||
display_mode: str | None = None
|
||||
skippable: bool = False
|
||||
auto_advance_if_single: bool = False
|
||||
|
||||
|
||||
class FlowStepSchema(BaseModel):
|
||||
id: str = Field(..., max_length=64)
|
||||
type: str = Field(..., max_length=32)
|
||||
label: str = Field(..., max_length=255)
|
||||
label_en: str | None = Field(default=None, max_length=255)
|
||||
enabled: bool = True
|
||||
page_slug: str | None = Field(default=None, max_length=64)
|
||||
config: FlowStepConfig = Field(default_factory=FlowStepConfig)
|
||||
|
||||
|
||||
class FlowDefinitionSchema(BaseModel):
|
||||
id: str = Field(..., max_length=64)
|
||||
name: str = Field(..., max_length=255)
|
||||
steps: list[FlowStepSchema] = Field(default_factory=list)
|
||||
version: int = 1
|
||||
|
||||
|
||||
class FlowDefinitionResponse(FlowDefinitionSchema):
|
||||
pass
|
||||
|
||||
|
||||
class FlowDefinitionUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, max_length=255)
|
||||
steps: list[FlowStepSchema] = Field(default_factory=list)
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class AccountSummaryResponse(BaseModel):
|
||||
identity_id: str
|
||||
email: str | None = None
|
||||
tg_id: int | None = None
|
||||
linked_telegram: bool = False
|
||||
referral_code: str = ""
|
||||
balance: float = 0.0
|
||||
trial_status: int = 0
|
||||
keys_total: int = 0
|
||||
referrals_total: int = 0
|
||||
referrals_active: int = 0
|
||||
referral_bonus_total: float = 0.0
|
||||
gifts_sent: int = 0
|
||||
gifts_claimed: int = 0
|
||||
coupons_used: int = 0
|
||||
partner_enabled: bool = False
|
||||
partner_code: str = ""
|
||||
partner_balance: float = 0.0
|
||||
partner_percent: float = 0.0
|
||||
partner_percent_custom: bool = False
|
||||
partner_referred_total: int = 0
|
||||
partner_payout_method: str | None = None
|
||||
unread_notifications: int = 0
|
||||
|
||||
|
||||
class AccountKeyActionsAvailability(BaseModel):
|
||||
can_connect_device: bool = False
|
||||
can_connect_router: bool = False
|
||||
can_connect_tv: bool = False
|
||||
can_renew: bool = False
|
||||
can_addons: bool = False
|
||||
can_reset_hwid: bool = False
|
||||
can_qr: bool = False
|
||||
can_delete: bool = False
|
||||
can_change_location: bool = False
|
||||
|
||||
|
||||
class AccountKeyDetailsResponse(BaseModel):
|
||||
client_id: str
|
||||
email: str
|
||||
alias: str | None = None
|
||||
expiry_time: int = 0
|
||||
is_frozen: bool = False
|
||||
tariff_name: str = ""
|
||||
subgroup_title: str = ""
|
||||
traffic_limit_gb: int = 0
|
||||
used_traffic_gb: float | None = None
|
||||
device_limit: int = 0
|
||||
connected_devices: int = 0
|
||||
is_tariff_configurable: bool = False
|
||||
addons_devices_enabled: bool = False
|
||||
addons_traffic_enabled: bool = False
|
||||
|
||||
|
||||
class AccountKeyResponse(BaseModel):
|
||||
email: str
|
||||
alias: str | None = None
|
||||
client_id: str
|
||||
tariff_id: int | None = None
|
||||
server_id: str
|
||||
created_at: int = 0
|
||||
expiry_time: int = 0
|
||||
key: str | None = None
|
||||
remnawave_link: str | None = None
|
||||
is_frozen: bool = False
|
||||
actions: AccountKeyActionsAvailability | None = None
|
||||
|
||||
|
||||
class AccountKeyAliasUpdateRequest(BaseModel):
|
||||
alias: str = Field(..., min_length=1, max_length=10)
|
||||
|
||||
|
||||
class AccountKeyActionResponse(BaseModel):
|
||||
ok: bool = True
|
||||
message: str = ""
|
||||
|
||||
|
||||
class AccountKeyRenewRequest(BaseModel):
|
||||
provider_id: str | None = None
|
||||
success_url: str | None = None
|
||||
failure_url: str | None = None
|
||||
coupon_code: str | None = None
|
||||
|
||||
|
||||
class AccountKeyRenewResponse(AccountKeyActionResponse):
|
||||
client_id: str
|
||||
tariff_id: int
|
||||
charged_rub: int = 0
|
||||
balance_rub: float = 0.0
|
||||
base_price_rub: int = 0
|
||||
discount_rub: int = 0
|
||||
final_price_rub: int = 0
|
||||
applied_coupon_code: str | None = None
|
||||
payment_required: bool = False
|
||||
required_amount_rub: int = 0
|
||||
payment_id: str | None = None
|
||||
payment_url: str | None = None
|
||||
|
||||
|
||||
class AccountKeyResetHwidResponse(AccountKeyActionResponse):
|
||||
total_devices: int = 0
|
||||
reset_devices: int = 0
|
||||
|
||||
|
||||
class AccountKeyQrResponse(AccountKeyActionResponse):
|
||||
link: str = ""
|
||||
image_data_url: str = ""
|
||||
|
||||
|
||||
class AccountKeyLocationOptionResponse(BaseModel):
|
||||
server_name: str
|
||||
|
||||
|
||||
class AccountKeyLocationsResponse(BaseModel):
|
||||
client_id: str
|
||||
current_server: str = ""
|
||||
locations: list[AccountKeyLocationOptionResponse] = []
|
||||
|
||||
|
||||
class AccountKeyChangeLocationRequest(BaseModel):
|
||||
server_name: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class AccountKeyChangeLocationResponse(AccountKeyActionResponse):
|
||||
client_id: str
|
||||
server_id: str = ""
|
||||
link: str = ""
|
||||
remnawave_link: str | None = None
|
||||
|
||||
|
||||
class AccountKeyAddonOptionResponse(BaseModel):
|
||||
value: int
|
||||
label: str = ""
|
||||
|
||||
|
||||
class AccountKeyAddonsPreviewRequest(BaseModel):
|
||||
selected_device_limit: int | None = None
|
||||
selected_traffic_gb: int | None = None
|
||||
include_device: bool | None = None
|
||||
include_traffic: bool | None = None
|
||||
provider_id: str | None = None
|
||||
success_url: str | None = None
|
||||
failure_url: str | None = None
|
||||
coupon_code: str | None = None
|
||||
|
||||
|
||||
class AccountKeyAddonsPreviewResponse(BaseModel):
|
||||
client_id: str
|
||||
tariff_id: int
|
||||
addons_mode: str = ""
|
||||
has_device_option: bool = False
|
||||
has_traffic_option: bool = False
|
||||
current_device_limit: int | None = None
|
||||
current_traffic_gb: int | None = None
|
||||
selected_device_limit: int | None = None
|
||||
selected_traffic_gb: int | None = None
|
||||
device_options: list[AccountKeyAddonOptionResponse] = []
|
||||
traffic_options: list[AccountKeyAddonOptionResponse] = []
|
||||
total_price_rub: int = 0
|
||||
extra_price_rub: int = 0
|
||||
discount_rub: int = 0
|
||||
final_price_rub: int = 0
|
||||
applied_coupon_code: str | None = None
|
||||
balance_rub: float = 0.0
|
||||
|
||||
|
||||
class AccountKeyApplyAddonsResponse(AccountKeyActionResponse):
|
||||
client_id: str
|
||||
tariff_id: int
|
||||
total_price_rub: int = 0
|
||||
extra_price_rub: int = 0
|
||||
discount_rub: int = 0
|
||||
final_price_rub: int = 0
|
||||
applied_coupon_code: str | None = None
|
||||
charged_rub: int = 0
|
||||
balance_rub: float = 0.0
|
||||
payment_required: bool = False
|
||||
required_amount_rub: int = 0
|
||||
payment_id: str | None = None
|
||||
payment_url: str | None = None
|
||||
|
||||
|
||||
class AccountKeyActionsConfigResponse(BaseModel):
|
||||
renew_enabled: bool = True
|
||||
delete_enabled: bool = False
|
||||
qr_enabled: bool = False
|
||||
hwid_reset_enabled: bool = False
|
||||
country_change_enabled: bool = False
|
||||
instructions_enabled: bool = False
|
||||
addons_enabled: bool = False
|
||||
addons_mode: str = ""
|
||||
tv_connect_enabled: bool = False
|
||||
|
||||
|
||||
class TariffConfigPriceResponse(BaseModel):
|
||||
price_rub: int
|
||||
|
||||
|
||||
class TariffPurchaseRequest(BaseModel):
|
||||
tariff_id: int = Field(..., ge=1)
|
||||
selected_device_limit: int | None = None
|
||||
selected_traffic_gb: int | None = None
|
||||
provider_id: str | None = None
|
||||
success_url: str | None = None
|
||||
failure_url: str | None = None
|
||||
coupon_code: str | None = None
|
||||
|
||||
|
||||
class TariffPurchaseResponse(BaseModel):
|
||||
ok: bool = True
|
||||
message: str = ""
|
||||
key_email: str | None = None
|
||||
charged_rub: int | None = None
|
||||
base_price_rub: int = 0
|
||||
discount_rub: int = 0
|
||||
final_price_rub: int = 0
|
||||
applied_coupon_code: str | None = None
|
||||
payment_required: bool = False
|
||||
required_amount_rub: int = 0
|
||||
payment_id: str | None = None
|
||||
payment_url: str | None = None
|
||||
|
||||
|
||||
class GiftCreateRequest(BaseModel):
|
||||
tariff_id: int = Field(..., ge=1)
|
||||
selected_device_limit: int | None = None
|
||||
selected_traffic_gb: int | None = None
|
||||
provider_id: str | None = None
|
||||
success_url: str | None = None
|
||||
failure_url: str | None = None
|
||||
|
||||
|
||||
class GiftCreatePreviewResponse(BaseModel):
|
||||
ok: bool = True
|
||||
price_rub: int = 0
|
||||
balance_rub: float = 0.0
|
||||
sufficient_funds: bool = True
|
||||
tariff_name: str = ""
|
||||
duration_days: int = 0
|
||||
|
||||
|
||||
class GiftCreateResponse(BaseModel):
|
||||
ok: bool = True
|
||||
message: str = ""
|
||||
gift_id: str = ""
|
||||
site_gift_link: str = ""
|
||||
tariff_name: str = ""
|
||||
duration_days: int = 0
|
||||
price_charged: int = 0
|
||||
balance_rub: float = 0.0
|
||||
payment_required: bool = False
|
||||
required_amount_rub: int = 0
|
||||
payment_id: str | None = None
|
||||
payment_url: str | None = None
|
||||
|
||||
|
||||
class GiftUsageEntry(BaseModel):
|
||||
user_id: int
|
||||
used_at: str | None = None
|
||||
|
||||
|
||||
class MyGiftItem(BaseModel):
|
||||
gift_id: str
|
||||
tariff_name: str = ""
|
||||
duration_days: int = 0
|
||||
price_rub: int = 0
|
||||
created_at: str | None = None
|
||||
expiry_time: str | None = None
|
||||
is_used: bool = False
|
||||
is_unlimited: bool = False
|
||||
max_usages: int | None = None
|
||||
site_gift_link: str = ""
|
||||
usages: list[GiftUsageEntry] = []
|
||||
|
||||
|
||||
class MyGiftsResponse(BaseModel):
|
||||
ok: bool = True
|
||||
gifts: list[MyGiftItem] = []
|
||||
total: int = 0
|
||||
limit: int = 20
|
||||
offset: int = 0
|
||||
|
||||
|
||||
class GiftRedeemRequest(BaseModel):
|
||||
gift_code: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class GiftRedeemResponse(BaseModel):
|
||||
ok: bool = True
|
||||
message: str = ""
|
||||
gift_id: str = ""
|
||||
tariff_id: int = 0
|
||||
duration_days: int = 0
|
||||
|
||||
|
||||
class ReferralApplyRequest(BaseModel):
|
||||
referrer_code: str | None = Field(None, min_length=1)
|
||||
referrer_tg_id: int | None = Field(None, ge=1)
|
||||
|
||||
|
||||
class ReferralApplyResponse(BaseModel):
|
||||
ok: bool = True
|
||||
message: str = ""
|
||||
referrer_code: str = ""
|
||||
referrer_user_id: int = 0
|
||||
referrer_tg_id: int | None = None
|
||||
referred_user_id: int = 0
|
||||
referred_tg_id: int | None = None
|
||||
|
||||
|
||||
class ReferralTopEntryResponse(BaseModel):
|
||||
position: int
|
||||
referrer_user_id: int
|
||||
referrals_count: int
|
||||
display_id: str
|
||||
|
||||
|
||||
class ReferralTopResponse(BaseModel):
|
||||
user_referrals_count: int = 0
|
||||
user_position: int | None = None
|
||||
top: list[ReferralTopEntryResponse] = []
|
||||
|
||||
|
||||
class ReferralQrResponse(BaseModel):
|
||||
ok: bool = True
|
||||
link: str = ""
|
||||
image_data_url: str = ""
|
||||
|
||||
|
||||
class ReferralConditionsResponse(BaseModel):
|
||||
title: str = ""
|
||||
summary: str = ""
|
||||
bonus_mode: str = ""
|
||||
bonus_mode_label: str = ""
|
||||
level_lines: list[str] = []
|
||||
rules: list[str] = []
|
||||
|
||||
|
||||
class PartnerConditionsResponse(BaseModel):
|
||||
title: str = ""
|
||||
summary: str = ""
|
||||
bonus_mode: str = ""
|
||||
bonus_mode_label: str = ""
|
||||
level_lines: list[str] = []
|
||||
rules: list[str] = []
|
||||
examples: list[str] = []
|
||||
min_payout_rub: float = 0.0
|
||||
payout_methods: list[str] = []
|
||||
custom_amount_enabled: bool = False
|
||||
|
||||
|
||||
class PartnerQrResponse(BaseModel):
|
||||
ok: bool = True
|
||||
link: str = ""
|
||||
image_data_url: str = ""
|
||||
|
||||
|
||||
class PartnerApplyRequest(BaseModel):
|
||||
partner_code: str | None = Field(None, min_length=1)
|
||||
partner_tg_id: int | None = Field(None, ge=1)
|
||||
|
||||
|
||||
class PartnerApplyResponse(BaseModel):
|
||||
ok: bool = True
|
||||
message: str = ""
|
||||
partner_code: str = ""
|
||||
partner_user_id: int = 0
|
||||
partner_tg_id: int | None = None
|
||||
joined_user_id: int = 0
|
||||
joined_tg_id: int | None = None
|
||||
|
||||
|
||||
class PartnerTopEntryResponse(BaseModel):
|
||||
position: int
|
||||
partner_user_id: int
|
||||
referred_count: int
|
||||
display_id: str
|
||||
|
||||
|
||||
class PartnerTopResponse(BaseModel):
|
||||
user_referred_count: int = 0
|
||||
user_position: int | None = None
|
||||
top: list[PartnerTopEntryResponse] = []
|
||||
|
||||
|
||||
class CouponApplyRequest(BaseModel):
|
||||
code: str = Field(..., min_length=1, max_length=128)
|
||||
|
||||
|
||||
class CouponApplyResponse(BaseModel):
|
||||
ok: bool = True
|
||||
message: str = ""
|
||||
coupon_code: str = ""
|
||||
amount: int = 0
|
||||
balance: float = 0.0
|
||||
|
||||
|
||||
class PartnerPayoutRequestCreate(BaseModel):
|
||||
amount_rub: float = Field(..., gt=0)
|
||||
|
||||
|
||||
class PartnerPayoutRequestResponse(BaseModel):
|
||||
ok: bool = True
|
||||
message: str = ""
|
||||
request_id: int | None = None
|
||||
amount_rub: float = 0.0
|
||||
status: str = "pending"
|
||||
balance_rub: float = 0.0
|
||||
|
||||
|
||||
class PartnerPayoutEntryResponse(BaseModel):
|
||||
id: int
|
||||
amount_rub: float = 0.0
|
||||
status: str = ""
|
||||
created_at: str | None = None
|
||||
method: str | None = None
|
||||
destination: str | None = None
|
||||
|
||||
|
||||
class PartnerPayoutHistoryResponse(BaseModel):
|
||||
total: int = 0
|
||||
items: list[PartnerPayoutEntryResponse] = []
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 *
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
from .resolution import *
|
||||
from .tg_mirror import *
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
@@ -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
@@ -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
@@ -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]:
|
||||
|
||||
@@ -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
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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} сброшены к выбранным")
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from .schema_upgrade import *
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
Reference in New Issue
Block a user