From aa086a57d2c47a6444e9e5457f92f188cc325bca Mon Sep 17 00:00:00 2001 From: Vladless Date: Sun, 3 May 2026 10:36:03 +0000 Subject: [PATCH] scale cabinet-mono pack architecture --- api/v2/routes/auth/session.py | 328 +++++++++++++++++++++++- api/v2/routes/keys/_common.py | 1 + api/v2/routes/keys/user/core.py | 54 ++++ api/v2/routes/partners.py | 37 +++ api/v2/routes/referrals.py | 32 +++ api/v2/routes/root.py | 39 ++- api/v2/schemas/web_public.py | 53 ++++ database/migrations/schema_upgrade.py | 20 ++ database/models/__init__.py | 2 + database/models/identity_notif_prefs.py | 31 +++ handlers/admin/users/users_manage.py | 33 ++- 11 files changed, 616 insertions(+), 14 deletions(-) create mode 100644 database/models/identity_notif_prefs.py diff --git a/api/v2/routes/auth/session.py b/api/v2/routes/auth/session.py index 32db8265..f8fb394d 100644 --- a/api/v2/routes/auth/session.py +++ b/api/v2/routes/auth/session.py @@ -1,5 +1,9 @@ -from fastapi import APIRouter, Depends, HTTPException, Request, Response -from sqlalchemy import func, select +import re + +from fastapi import APIRouter, Depends, HTTPException, Path, Request, Response +from fastapi.responses import HTMLResponse +from pydantic import BaseModel +from sqlalchemy import String, func, select from sqlalchemy.ext.asyncio import AsyncSession from api.depends import ( @@ -19,7 +23,11 @@ from api.v2.schemas.identities import ( IdentitySessionsResponse, SetPasswordRequest, ) -from api.v2.schemas.web_public import AccountSummaryResponse +from api.v2.schemas.web_public import ( + AccountSearchHit, + AccountSearchResponse, + AccountSummaryResponse, +) from database import ( get_balance, get_keys, @@ -27,7 +35,7 @@ from database import ( identities as idb, identity_sessions as idsess, ) -from database.models import CouponUsage, Gift, GiftUsage +from database.models import CouponUsage, Gift, GiftUsage, IdentityNotifPref, Key, Payment, WebNotification from database.referrals import get_referral_stats from database.web_notifications import count_unread_for_identity from utils.referral_codes import encode_referral_code @@ -208,6 +216,8 @@ async def auth_summary( email=identity.email, tg_id=identity.tg_id, linked_telegram=identity.tg_id is not None, + created_at=identity.created_at.isoformat() if identity.created_at else None, + password_set=bool(identity.password_set), referral_code=encode_referral_code(int(billing_user_id)), balance=balance, trial_status=int(trial_status), @@ -230,6 +240,316 @@ async def auth_summary( ) +class MyPaymentItem(BaseModel): + id: int + amount: float + currency: str + status: str + provider: str + created_at: str | None + purpose: str | None + + +class MyPaymentsResponse(BaseModel): + ok: bool = True + payments: list[MyPaymentItem] + + +@router.get("/me/payments", response_model=MyPaymentsResponse) +async def my_payments( + request: Request, + session: AsyncSession = Depends(get_session), + identity=Depends(verify_identity_token), + limit: int = 50, +): + """История платежей текущего юзера. Привязка через Identity → User → Payment.""" + 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) + if billing_user_id is None: + return MyPaymentsResponse(ok=True, payments=[]) + safe_limit = max(1, min(200, int(limit) if limit else 50)) + rows = await session.execute( + select(Payment) + .where(Payment.user_id == billing_user_id) + .order_by(Payment.created_at.desc()) + .limit(safe_limit) + ) + payments = rows.scalars().all() + items: list[MyPaymentItem] = [] + for p in payments: + meta = p.metadata_ if isinstance(p.metadata_, dict) else None + purpose = None + if meta: + purpose = meta.get("purpose") or meta.get("description") or meta.get("tariff_name") + if purpose is not None: + purpose = str(purpose) + items.append( + MyPaymentItem( + id=int(p.id), + amount=float(p.amount or 0), + currency=str(p.currency or "RUB"), + status=str(p.status or ""), + provider=str(p.payment_system or ""), + created_at=p.created_at.isoformat() if p.created_at else None, + purpose=purpose, + ) + ) + return MyPaymentsResponse(ok=True, payments=items) + + +def _esc(value: object) -> str: + s = "" if value is None else str(value) + return ( + s.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + .replace("'", "'") + ) + + +@router.get("/me/payments/{payment_id}/invoice", response_class=HTMLResponse) +async def get_my_payment_invoice( + payment_id: int = Path(..., ge=1), + request: Request = None, # type: ignore[assignment] + session: AsyncSession = Depends(get_session), + identity=Depends(verify_identity_token), +): + """HTML-инвойс по конкретному платежу. Браузер может сохранить как PDF (Cmd+P → Save as PDF).""" + actor = get_request_actor(request) if request is not None else None + 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) + if billing_user_id is None: + raise HTTPException(status_code=404, detail="Платёж не найден") + payment = ( + await session.execute( + select(Payment).where(Payment.id == payment_id, Payment.user_id == billing_user_id).limit(1) + ) + ).scalar_one_or_none() + if payment is None: + raise HTTPException(status_code=404, detail="Платёж не найден") + meta = payment.metadata_ if isinstance(payment.metadata_, dict) else {} + purpose = "" + if meta: + v = meta.get("purpose") or meta.get("description") or meta.get("tariff_name") + if v is not None: + purpose = str(v) + created = payment.created_at.strftime("%d.%m.%Y %H:%M") if payment.created_at else "—" + amount_value = float(payment.amount or 0) + currency = str(payment.currency or "RUB").upper() + status_raw = str(payment.status or "") + status_norm = status_raw.lower() + status_label = "ОПЛАЧЕН" if status_norm in {"completed", "success", "paid"} else "ОЖИДАЕТ" if status_norm in {"pending", "processing"} else "ОТКЛОНЁН" + provider = str(payment.payment_system or "").upper() or "—" + user_label = identity.email or (f"tg · {identity.tg_id}" if identity.tg_id else identity.id) + html = f""" + + + + Квитанция #{_esc(payment.id)} + + + + +

Квитанция #{_esc(payment.id)}

+
// {_esc(created)}
+
{amount_value:,.2f} {_esc(currency)}
+ {_esc(status_label)} + + + + + + +
Назначение{_esc(purpose) or "—"}
Провайдер{_esc(provider)}
Дата{_esc(created)}
Получатель{_esc(user_label)}
Идентификатор платежа{_esc(payment.id)}
+
Документ сгенерирован автоматически. Не требует подписи и печати.
+ +""" + return HTMLResponse(content=html, status_code=200) + + +class NotifChannelPref(BaseModel): + channel: str + enabled: bool + + +class NotifChannelPrefsResponse(BaseModel): + ok: bool = True + channels: list[NotifChannelPref] + + +class NotifChannelPrefsUpdateRequest(BaseModel): + channels: list[NotifChannelPref] + + +_NOTIF_CHANNEL_RE = re.compile(r"^[a-zA-Z0-9_-]{1,32}$") + + +@router.get("/me/notification-prefs", response_model=NotifChannelPrefsResponse) +async def get_my_notification_prefs( + session: AsyncSession = Depends(get_session), + identity=Depends(verify_identity_token), +): + rows = ( + await session.execute( + select(IdentityNotifPref).where(IdentityNotifPref.identity_id == identity.id) + ) + ).scalars().all() + return NotifChannelPrefsResponse( + ok=True, + channels=[NotifChannelPref(channel=str(r.channel), enabled=bool(r.enabled)) for r in rows], + ) + + +@router.put("/me/notification-prefs", response_model=NotifChannelPrefsResponse) +async def set_my_notification_prefs( + body: NotifChannelPrefsUpdateRequest, + session: AsyncSession = Depends(get_session), + identity=Depends(verify_identity_token), +): + for entry in body.channels: + channel = str(entry.channel or "").strip() + if not channel or not _NOTIF_CHANNEL_RE.match(channel): + raise HTTPException(status_code=422, detail=f"Некорректный канал: {channel!r}") + existing = ( + await session.execute( + select(IdentityNotifPref).where( + IdentityNotifPref.identity_id == identity.id, + IdentityNotifPref.channel == channel, + ) + ) + ).scalar_one_or_none() + if existing is None: + session.add( + IdentityNotifPref(identity_id=identity.id, channel=channel, enabled=bool(entry.enabled)) + ) + else: + existing.enabled = bool(entry.enabled) + await session.flush() + rows = ( + await session.execute( + select(IdentityNotifPref).where(IdentityNotifPref.identity_id == identity.id) + ) + ).scalars().all() + return NotifChannelPrefsResponse( + ok=True, + channels=[NotifChannelPref(channel=str(r.channel), enabled=bool(r.enabled)) for r in rows], + ) + + +@router.get("/me/search", response_model=AccountSearchResponse) +async def my_search( + q: str, + request: Request, + session: AsyncSession = Depends(get_session), + identity=Depends(verify_identity_token), + limit: int = 8, +): + """Поиск по подпискам, платежам, уведомлениям текущего user'а. Простое ILIKE.""" + query_raw = (q or "").strip() + if len(query_raw) < 2: + return AccountSearchResponse(query=query_raw, hits=[], total=0) + 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) + if billing_user_id is None: + return AccountSearchResponse(query=query_raw, hits=[], total=0) + safe_limit = max(1, min(20, int(limit) if limit else 8)) + pattern = f"%{query_raw.lower()}%" + hits: list[AccountSearchHit] = [] + + # Keys: alias / email / server_id + keys_rows = ( + await session.execute( + select(Key) + .where(Key.user_id == billing_user_id) + .where( + func.lower(func.coalesce(Key.alias, "")) + .like(pattern) + | func.lower(func.coalesce(Key.email, "")).like(pattern) + | func.lower(func.coalesce(Key.server_id, "")).like(pattern) + | func.lower(func.coalesce(Key.client_id, "")).like(pattern) + ) + .limit(safe_limit) + ) + ).scalars().all() + for k in keys_rows: + label = (k.alias or k.email or k.client_id or "").strip() or "—" + sublabel = (k.server_id or "").strip() or "—" + hits.append(AccountSearchHit(kind="subscription", label=label, sublabel=sublabel, href="/dashboard/keys", meta=str(k.client_id))) + + # Payments: provider / metadata.purpose + payments_rows = ( + await session.execute( + select(Payment) + .where(Payment.user_id == billing_user_id) + .where( + func.lower(func.coalesce(Payment.payment_system, "")).like(pattern) + | func.cast(Payment.metadata_, String).ilike(pattern) + ) + .order_by(Payment.created_at.desc()) + .limit(safe_limit) + ) + ).scalars().all() + for p in payments_rows: + meta = p.metadata_ if isinstance(p.metadata_, dict) else None + purpose = "" + if meta: + v = meta.get("purpose") or meta.get("description") or meta.get("tariff_name") + if v is not None: + purpose = str(v) + amount_label = f"{float(p.amount or 0):,.0f} {(p.currency or 'RUB').upper()}" + hits.append(AccountSearchHit( + kind="payment", + label=purpose or amount_label, + sublabel=f"{(p.payment_system or '').upper()} · {amount_label}", + href="/dashboard", + meta=str(p.id), + )) + + # Notifications: title / message + notif_rows = ( + await session.execute( + select(WebNotification) + .where(WebNotification.identity_id == identity.id) + .where( + func.lower(WebNotification.title).like(pattern) + | func.lower(WebNotification.message).like(pattern) + ) + .order_by(WebNotification.created_at.desc()) + .limit(safe_limit) + ) + ).scalars().all() + for n in notif_rows: + hits.append(AccountSearchHit( + kind="notification", + label=str(n.title or "—"), + sublabel=(str(n.message or "")[:80]), + href="/dashboard/notifications", + meta=str(n.id), + )) + + return AccountSearchResponse(query=query_raw, hits=hits, total=len(hits)) + + @router.post("/set-password") async def set_password( body: SetPasswordRequest, diff --git a/api/v2/routes/keys/_common.py b/api/v2/routes/keys/_common.py index 7ba1428f..a0ef0007 100644 --- a/api/v2/routes/keys/_common.py +++ b/api/v2/routes/keys/_common.py @@ -35,6 +35,7 @@ from api.v2.schemas.web_public import ( AccountKeyApplyAddonsResponse, AccountKeyChangeLocationRequest, AccountKeyChangeLocationResponse, + AccountKeyConnectionResponse, AccountKeyDetailsResponse, AccountKeyLocationOptionResponse, AccountKeyLocationsResponse, diff --git a/api/v2/routes/keys/user/core.py b/api/v2/routes/keys/user/core.py index 1c169212..86efc17e 100644 --- a/api/v2/routes/keys/user/core.py +++ b/api/v2/routes/keys/user/core.py @@ -4,6 +4,8 @@ из ``__init__.py`` запускает регистрацию декораторов. """ +import time + from .._common import * # noqa: F401,F403 — подтягиваем все имена для endpoints from .._common import ( _key_actions_config, @@ -60,6 +62,58 @@ async def user_keys_actions_config( return _key_actions_config() +@user_router.get("/{client_id}/connection", response_model=AccountKeyConnectionResponse) +async def user_key_connection( + client_id: str, + request: Request, + session: AsyncSession = Depends(get_session), + identity=Depends(verify_identity_token), +): + """Лёгкая инфо о текущей подписке: онлайн/offline, сервер, протокол, дни до окончания.""" + 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_name = str(getattr(db_key, "server_id", "") or "") + cluster_name = "" + panel_type = "" + if server_name: + srv = ( + await session.execute( + select(Server).where(Server.server_name == server_name).limit(1) + ) + ).scalar_one_or_none() + if srv is not None: + cluster_name = str(getattr(srv, "cluster_name", "") or "") + panel_type = str(getattr(srv, "panel_type", "") or "").lower() + expiry_ms = int(getattr(db_key, "expiry_time", 0) or 0) + is_frozen = bool(getattr(db_key, "is_frozen", False)) + now_ms = int(time.time() * 1000) + online = not is_frozen and expiry_ms > now_ms + expires_in_days = max(0, int((expiry_ms - now_ms) / (1000 * 60 * 60 * 24))) if expiry_ms > 0 else 0 + if panel_type == "remnawave": + protocol = "VLESS" + elif panel_type == "marzban": + protocol = "VLESS" + elif panel_type == "3xui": + protocol = "VLESS" + else: + protocol = panel_type.upper() or "VLESS" + return AccountKeyConnectionResponse( + client_id=str(getattr(db_key, "client_id", "") or ""), + online=online, + is_frozen=is_frozen, + expiry_time=expiry_ms, + expires_in_days=expires_in_days, + server_name=server_name, + cluster_name=cluster_name, + panel_type=panel_type, + protocol=protocol, + ) + + @user_router.get("/{client_id}/details", response_model=AccountKeyDetailsResponse) async def user_key_details( client_id: str, diff --git a/api/v2/routes/partners.py b/api/v2/routes/partners.py index e7d24aaa..12c310d9 100644 --- a/api/v2/routes/partners.py +++ b/api/v2/routes/partners.py @@ -18,6 +18,8 @@ from api.v2.schemas.web_public import ( PartnerApplyRequest, PartnerApplyResponse, PartnerConditionsResponse, + PartnerInvitedEntry, + PartnerInvitedResponse, PartnerPayoutEntryResponse, PartnerPayoutHistoryResponse, PartnerPayoutRequestCreate, @@ -238,6 +240,41 @@ async def partner_apply( ) +@router.get("/invited/me", response_model=PartnerInvitedResponse) +async def partner_me_invited( + request: Request, + limit: int = Query(50, ge=1, le=200), + session: AsyncSession = Depends(get_session), + identity=Depends(verify_identity_token), +): + _, tg_id = await _resolve_partner_user(session, request, identity) + invited_sql = text( + """ + SELECT pr.joined_tg_id, pr.created_at, COALESCE(u.balance, 0), + (SELECT COUNT(*) FROM keys k WHERE k.tg_id = pr.joined_tg_id), + (SELECT COUNT(*) FROM payments pay WHERE pay.tg_id = pr.joined_tg_id AND lower(pay.status) = 'success') + FROM partners pr + LEFT JOIN users u ON u.tg_id = pr.joined_tg_id + WHERE pr.partner_tg_id = :tg_id + ORDER BY pr.created_at DESC + LIMIT :limit + """ + ) + result = await session.execute(invited_sql, {"tg_id": tg_id, "limit": limit}) + rows = result.fetchall() + items = [ + PartnerInvitedEntry( + tg_id=int(row[0]), + joined_at=row[1].isoformat() if isinstance(row[1], datetime) else None, + balance=float(row[2] or 0), + keys_count=int(row[3] or 0), + payments_count=int(row[4] or 0), + ) + for row in rows + ] + return PartnerInvitedResponse(total=len(items), items=items) + + @router.get("/qr", response_model=PartnerQrResponse) async def partner_qr( request: Request, diff --git a/api/v2/routes/referrals.py b/api/v2/routes/referrals.py index 23e6e103..a8d5d0aa 100644 --- a/api/v2/routes/referrals.py +++ b/api/v2/routes/referrals.py @@ -12,10 +12,14 @@ from api.v2.schemas.web_public import ( ReferralApplyRequest, ReferralApplyResponse, ReferralConditionsResponse, + ReferralListEntry, + ReferralListResponse, ReferralQrResponse, ReferralTopEntryResponse, ReferralTopResponse, ) +from sqlalchemy import select +from database.models import Referral from config import ( CHECK_REFERRAL_REWARD_ISSUED, REFERRAL_BONUS_PERCENTAGES, @@ -155,6 +159,34 @@ async def referral_top( ) +@router.get("/list", response_model=ReferralListResponse, tags=["Referrals"]) +async def referral_list( + limit: int = Query(50, ge=1, le=200), + 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="Реферальная программа отключена") + billing_uid = await idb.ensure_billing_user_for_identity(session, identity) + rows_stmt = ( + select(Referral) + .where(Referral.referrer_user_id == int(billing_uid)) + .limit(limit) + ) + result = await session.execute(rows_stmt) + rows = result.scalars().all() + items = [ + ReferralListEntry( + referred_user_id=int(r.referred_user_id), + referred_tg_id=int(r.referred_tg_id) if r.referred_tg_id is not None else None, + display_id=encode_referral_code(int(r.referred_user_id)), + reward_issued=bool(r.reward_issued), + ) + for r in rows + ] + return ReferralListResponse(total=len(items), items=items) + + @router.get("/qr", response_model=ReferralQrResponse, tags=["Referrals"]) async def referral_qr( request: Request, diff --git a/api/v2/routes/root.py b/api/v2/routes/root.py index 2326fe41..144e7edb 100644 --- a/api/v2/routes/root.py +++ b/api/v2/routes/root.py @@ -198,7 +198,7 @@ async def site_config(): _UPDATE_CHECK_CACHE: dict[str, object] = {"fetched_at": 0.0, "data": None} _UPDATE_CHECK_LOCK = asyncio.Lock() -_UPDATE_CHECK_TTL_SEC = 3600 +_UPDATE_CHECK_TTL_SEC = 600 _SEMVER_RE = re.compile( r"^v?(?P\d+)\.(?P\d+)\.(?P\d+)(?:-(?P
[0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$"
 )
@@ -230,8 +230,8 @@ def _parse_semver(tag: str) -> tuple[int, int, int, int, tuple[tuple[int, int |
 
 
 async def _fetch_ghcr_tags(image: str) -> list[str]:
-    """Возвращает все теги образа в GHCR."""
-    async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session:
+    """Возвращает все теги образа в GHCR. Поддерживает paginate через Link header."""
+    async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=15)) as session:
         token_url = f"https://ghcr.io/token?scope=repository:{image}:pull"
         async with session.get(token_url) as token_resp:
             if token_resp.status != 200:
@@ -241,12 +241,33 @@ async def _fetch_ghcr_tags(image: str) -> list[str]:
             if not token:
                 return []
         headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
-        tags_url = f"https://ghcr.io/v2/{image}/tags/list"
-        async with session.get(tags_url, headers=headers) as tags_resp:
-            if tags_resp.status != 200:
-                return []
-            payload = await tags_resp.json()
-    return payload.get("tags") or []
+        all_tags: list[str] = []
+        next_url: str | None = f"https://ghcr.io/v2/{image}/tags/list?n=1000"
+        guard = 0
+        while next_url and guard < 20:
+            guard += 1
+            async with session.get(next_url, headers=headers) as tags_resp:
+                if tags_resp.status != 200:
+                    break
+                payload = await tags_resp.json()
+                page_tags = payload.get("tags") or []
+                if isinstance(page_tags, list):
+                    all_tags.extend(str(t) for t in page_tags)
+                link_header = tags_resp.headers.get("Link") or ""
+            next_url = None
+            for part in link_header.split(","):
+                part = part.strip()
+                if not part or 'rel="next"' not in part:
+                    continue
+                inner = part.split(";", 1)[0].strip()
+                if inner.startswith("<") and inner.endswith(">"):
+                    inner = inner[1:-1]
+                if inner.startswith("/"):
+                    next_url = f"https://ghcr.io{inner}"
+                else:
+                    next_url = inner
+                break
+        return all_tags
 
 
 def _is_dev_version(v: str) -> bool:
diff --git a/api/v2/schemas/web_public.py b/api/v2/schemas/web_public.py
index a5803867..52eb4c0d 100644
--- a/api/v2/schemas/web_public.py
+++ b/api/v2/schemas/web_public.py
@@ -6,6 +6,8 @@ class AccountSummaryResponse(BaseModel):
     email: str | None = None
     tg_id: int | None = None
     linked_telegram: bool = False
+    created_at: str | None = None
+    password_set: bool = False
     referral_code: str = ""
     balance: float = 0.0
     trial_status: int = 0
@@ -196,6 +198,32 @@ class AccountKeyActionsConfigResponse(BaseModel):
     tv_connect_enabled: bool = False
 
 
+class AccountKeyConnectionResponse(BaseModel):
+    client_id: str
+    online: bool = False
+    is_frozen: bool = False
+    expiry_time: int = 0
+    expires_in_days: int = 0
+    server_name: str = ""
+    cluster_name: str = ""
+    panel_type: str = ""
+    protocol: str = ""
+
+
+class AccountSearchHit(BaseModel):
+    kind: str
+    label: str
+    sublabel: str = ""
+    href: str = ""
+    meta: str = ""
+
+
+class AccountSearchResponse(BaseModel):
+    query: str
+    hits: list[AccountSearchHit] = []
+    total: int = 0
+
+
 class TariffConfigPriceResponse(BaseModel):
     price_rub: int
 
@@ -325,6 +353,18 @@ class ReferralTopResponse(BaseModel):
     top: list[ReferralTopEntryResponse] = []
 
 
+class ReferralListEntry(BaseModel):
+    referred_user_id: int
+    referred_tg_id: int | None = None
+    display_id: str = ""
+    reward_issued: bool = False
+
+
+class ReferralListResponse(BaseModel):
+    total: int = 0
+    items: list[ReferralListEntry] = []
+
+
 class ReferralQrResponse(BaseModel):
     ok: bool = True
     link: str = ""
@@ -387,6 +427,19 @@ class PartnerTopResponse(BaseModel):
     top: list[PartnerTopEntryResponse] = []
 
 
+class PartnerInvitedEntry(BaseModel):
+    tg_id: int
+    joined_at: str | None = None
+    balance: float = 0.0
+    keys_count: int = 0
+    payments_count: int = 0
+
+
+class PartnerInvitedResponse(BaseModel):
+    total: int = 0
+    items: list[PartnerInvitedEntry] = []
+
+
 class CouponApplyRequest(BaseModel):
     code: str = Field(..., min_length=1, max_length=128)
 
diff --git a/database/migrations/schema_upgrade.py b/database/migrations/schema_upgrade.py
index 6c6bc4ac..3d754b40 100644
--- a/database/migrations/schema_upgrade.py
+++ b/database/migrations/schema_upgrade.py
@@ -1266,6 +1266,25 @@ async def _migration_v27_add_admins_permissions(conn: AsyncConnection) -> None:
         )
 
 
+async def _migration_v28_add_identity_notif_prefs(conn: AsyncConnection) -> None:
+    logger.info("[schema_upgrade] v28: таблица identity_notif_prefs (toggle каналов уведомлений)")
+    if not await _table_exists(conn, "identities"):
+        return
+    if not await _table_exists(conn, "identity_notif_prefs"):
+        await _exec_ignore(
+            conn,
+            """
+            CREATE TABLE identity_notif_prefs (
+                identity_id VARCHAR(36) NOT NULL REFERENCES identities(id) ON DELETE CASCADE,
+                channel VARCHAR(32) NOT NULL,
+                enabled BOOLEAN NOT NULL DEFAULT TRUE,
+                updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+                PRIMARY KEY (identity_id, channel)
+            )
+            """,
+        )
+
+
 async def _migration_v24_add_identity_sessions(conn: AsyncConnection) -> None:
     logger.info("[schema_upgrade] v24: таблица identity_sessions + перенос существующих токенов")
     if not await _table_exists(conn, "identities"):
@@ -1346,6 +1365,7 @@ _MIGRATIONS = [
     (25, "индексы на partners(partner_tg_id/joined_tg_id)", _migration_v25_add_partners_indexes),
     (26, "индексы keys(expiry_time/server_id/tariff_id)", _migration_v26_add_keys_indexes),
     (27, "admins.permissions (JSONB per-admin permissions)", _migration_v27_add_admins_permissions),
+    (28, "таблица identity_notif_prefs (toggle каналов)", _migration_v28_add_identity_notif_prefs),
 ]
 
 
diff --git a/database/models/__init__.py b/database/models/__init__.py
index d997f08f..083d2704 100644
--- a/database/models/__init__.py
+++ b/database/models/__init__.py
@@ -4,6 +4,7 @@ from .audit import AuditEvent
 from .coupons import Coupon, CouponUsage
 from .gifts import Gift, GiftUsage
 from .identity import Identity
+from .identity_notif_prefs import IdentityNotifPref
 from .identity_session import IdentitySession
 from .keys import Key
 from .notifications import Notification, ScheduledBroadcast
@@ -31,6 +32,7 @@ __all__ = [
     "Base",
     "DictLikeMixin",
     "Identity",
+    "IdentityNotifPref",
     "IdentitySession",
     "User",
     "ManualBan",
diff --git a/database/models/identity_notif_prefs.py b/database/models/identity_notif_prefs.py
new file mode 100644
index 00000000..13d5e3e2
--- /dev/null
+++ b/database/models/identity_notif_prefs.py
@@ -0,0 +1,31 @@
+from datetime import datetime
+
+from sqlalchemy import (
+    Boolean,
+    Column,
+    DateTime,
+    ForeignKey,
+    PrimaryKeyConstraint,
+    String,
+)
+
+from ._base import Base, DictLikeMixin
+
+
+class IdentityNotifPref(DictLikeMixin, Base):
+    """Пользовательские настройки каналов доставки уведомлений."""
+
+    __tablename__ = "identity_notif_prefs"
+
+    identity_id = Column(
+        String(36),
+        ForeignKey("identities.id", ondelete="CASCADE"),
+        nullable=False,
+    )
+    channel = Column(String(32), nullable=False)
+    enabled = Column(Boolean, nullable=False, default=True)
+    updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
+
+    __table_args__ = (
+        PrimaryKeyConstraint("identity_id", "channel"),
+    )
diff --git a/handlers/admin/users/users_manage.py b/handlers/admin/users/users_manage.py
index 4820046e..ff1b0386 100644
--- a/handlers/admin/users/users_manage.py
+++ b/handlers/admin/users/users_manage.py
@@ -1,3 +1,5 @@
+import re
+
 import pytz
 
 from aiogram import F, Router, types
@@ -39,6 +41,7 @@ from .users_states import UserEditorState
 
 
 MOSCOW_TZ = pytz.timezone("Europe/Moscow")
+UUID_RE = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
 
 router = Router()
 
@@ -50,10 +53,11 @@ router = Router()
 async def handle_search_user(callback_query: CallbackQuery, state: FSMContext):
     text = (
         "🔍 Поиск пользователя"
-        "\n\n📌 Введите ID, Username, Email или перешлите сообщение пользователя."
+        "\n\n📌 Введите ID, Username, Email, UUID веб-аккаунта или перешлите сообщение пользователя."
         "\n\n🆔 ID - числовой айди"
         "\n📝 Username - юзернейм пользователя"
         "\n📧 Email - почта веб-кабинета"
+        "\n🧬 UUID - идентификатор веб-аккаунта (identity_id)"
         "\n\n✉️ Для поиска, вы можете просто переслать сообщение от пользователя."
     )
 
@@ -111,6 +115,33 @@ async def handle_user_data_input(message: Message, state: FSMContext, session: A
 
     if raw.isdigit():
         tg_id = int(raw)
+    elif UUID_RE.match(raw):
+        identity_id = raw.lower()
+        ident = (
+            await session.execute(select(Identity).where(func.lower(Identity.id) == identity_id).limit(1))
+        ).scalar_one_or_none()
+
+        if ident is None:
+            await message.answer(
+                text="🚫 Веб-аккаунт с указанным UUID не найден!",
+                reply_markup=kb,
+            )
+            return
+
+        if ident.tg_id is not None:
+            tg_id = ident.tg_id
+        else:
+            user_id = (
+                await session.execute(select(User.id).where(User.identity_id == ident.id).limit(1))
+            ).scalar_one_or_none()
+            if user_id is None:
+                label = ident.email or ident.id
+                await message.answer(
+                    text=f"🚫 Веб-аккаунт {label} не имеет биллинг-профиля.",
+                    reply_markup=kb,
+                )
+                return
+            tg_id = user_id
     elif "@" in raw and "." in raw.split("@", 1)[-1]:
         email = raw.lower()
         ident = (