refactor: improve startup and sync performance
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
import asyncio
|
||||
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
from bot.utils.ttl_cache import AsyncTTLCache
|
||||
|
||||
_ADMIN_PANEL_STATS_CACHES: Dict[tuple[int, int], AsyncTTLCache] = {}
|
||||
|
||||
|
||||
async def admin_me_route(request: web.Request) -> web.Response:
|
||||
@@ -36,47 +41,7 @@ async def admin_stats_route(request: web.Request) -> web.Response:
|
||||
|
||||
panel_service = request.app.get("panel_service")
|
||||
if panel_service is not None:
|
||||
try:
|
||||
system = await panel_service.get_system_stats()
|
||||
bandwidth = await panel_service.get_bandwidth_stats()
|
||||
panel_body: Dict[str, Any] = {
|
||||
"system": system or {},
|
||||
"bandwidth": bandwidth or {},
|
||||
}
|
||||
try:
|
||||
nodes = await panel_service.get_nodes_statistics()
|
||||
panel_body["nodes"] = nodes or {}
|
||||
except Exception as exc_nodes: # pragma: no cover - optional endpoint
|
||||
logger.debug("Panel nodes stats unavailable: %s", exc_nodes)
|
||||
panel_body["nodes"] = {}
|
||||
try:
|
||||
today = datetime.now(timezone.utc).date()
|
||||
start_d = today - timedelta(days=7)
|
||||
nodes_bw = await panel_service.get_nodes_bandwidth_usage(
|
||||
start=start_d.isoformat(),
|
||||
end=today.isoformat(),
|
||||
top_nodes_limit=64,
|
||||
)
|
||||
panel_body["nodes_bandwidth"] = nodes_bw or {}
|
||||
except Exception as exc_nb: # pragma: no cover - optional endpoint
|
||||
logger.debug("Panel nodes bandwidth range unavailable: %s", exc_nb)
|
||||
panel_body["nodes_bandwidth"] = {}
|
||||
try:
|
||||
online_map = _panel_nodes_online_by_uuid(panel_body.get("nodes"))
|
||||
lookups = await panel_service.get_nodes_online_lookups()
|
||||
for k, v in lookups.get("byUuid", {}).items():
|
||||
online_map[k] = v
|
||||
_enrich_bandwidth_nodes_with_online(
|
||||
panel_body.get("nodes_bandwidth"),
|
||||
online_map,
|
||||
lookups.get("byName") or {},
|
||||
)
|
||||
except Exception as exc_merge: # pragma: no cover
|
||||
logger.debug("Panel nodes online merge skipped: %s", exc_merge)
|
||||
payload["panel"] = panel_body
|
||||
except Exception as exc:
|
||||
logger.debug("Panel stats unavailable: %s", exc)
|
||||
payload["panel"] = {"error": "unavailable"}
|
||||
payload["panel"] = await _load_admin_panel_stats(request, settings, panel_service)
|
||||
|
||||
queue_manager = get_queue_manager()
|
||||
if queue_manager:
|
||||
@@ -87,3 +52,81 @@ async def admin_stats_route(request: web.Request) -> web.Response:
|
||||
|
||||
payload["currency_symbol"] = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
||||
return _ok(payload)
|
||||
|
||||
|
||||
async def _load_admin_panel_stats(
|
||||
request: web.Request,
|
||||
settings: Settings,
|
||||
panel_service,
|
||||
) -> Dict[str, Any]:
|
||||
cache = _admin_panel_stats_cache(settings)
|
||||
if cache is None:
|
||||
return await _load_admin_panel_stats_uncached(panel_service)
|
||||
return await cache.get_or_load("panel", lambda: _load_admin_panel_stats_uncached(panel_service))
|
||||
|
||||
|
||||
def _admin_panel_stats_cache(settings: Settings) -> Optional[AsyncTTLCache]:
|
||||
ttl_seconds = int(getattr(settings, "ADMIN_PANEL_STATS_CACHE_TTL_SECONDS", 15) or 0)
|
||||
if ttl_seconds <= 0:
|
||||
return None
|
||||
cache_key = (id(settings), ttl_seconds)
|
||||
cache = _ADMIN_PANEL_STATS_CACHES.get(cache_key)
|
||||
if cache is None:
|
||||
cache = AsyncTTLCache(
|
||||
ttl_seconds=ttl_seconds,
|
||||
settings=settings,
|
||||
namespace="admin:panel_stats",
|
||||
)
|
||||
_ADMIN_PANEL_STATS_CACHES[cache_key] = cache
|
||||
return cache
|
||||
|
||||
|
||||
async def _load_admin_panel_stats_uncached(panel_service) -> Dict[str, Any]:
|
||||
try:
|
||||
today = datetime.now(timezone.utc).date()
|
||||
start_d = today - timedelta(days=7)
|
||||
system, bandwidth, nodes, nodes_bw, lookups = await asyncio.gather(
|
||||
_safe_panel_call(panel_service.get_system_stats(), "system stats"),
|
||||
_safe_panel_call(panel_service.get_bandwidth_stats(), "bandwidth stats"),
|
||||
_safe_panel_call(panel_service.get_nodes_statistics(), "nodes stats"),
|
||||
_safe_panel_call(
|
||||
panel_service.get_nodes_bandwidth_usage(
|
||||
start=start_d.isoformat(),
|
||||
end=today.isoformat(),
|
||||
top_nodes_limit=64,
|
||||
),
|
||||
"nodes bandwidth range",
|
||||
),
|
||||
_safe_panel_call(panel_service.get_nodes_online_lookups(), "nodes online lookups"),
|
||||
)
|
||||
|
||||
panel_body: Dict[str, Any] = {
|
||||
"system": system or {},
|
||||
"bandwidth": bandwidth or {},
|
||||
"nodes": nodes or {},
|
||||
"nodes_bandwidth": nodes_bw or {},
|
||||
}
|
||||
if isinstance(lookups, dict):
|
||||
try:
|
||||
online_map = _panel_nodes_online_by_uuid(panel_body.get("nodes"))
|
||||
for k, v in lookups.get("byUuid", {}).items():
|
||||
online_map[k] = v
|
||||
_enrich_bandwidth_nodes_with_online(
|
||||
panel_body.get("nodes_bandwidth"),
|
||||
online_map,
|
||||
lookups.get("byName") or {},
|
||||
)
|
||||
except Exception as exc_merge: # pragma: no cover
|
||||
logger.debug("Panel nodes online merge skipped: %s", exc_merge)
|
||||
return panel_body
|
||||
except Exception as exc:
|
||||
logger.debug("Panel stats unavailable: %s", exc)
|
||||
return {"error": "unavailable"}
|
||||
|
||||
|
||||
async def _safe_panel_call(awaitable, label: str) -> Any:
|
||||
try:
|
||||
return await awaitable
|
||||
except Exception as exc: # pragma: no cover - optional panel endpoints
|
||||
logger.debug("Panel %s unavailable: %s", label, exc)
|
||||
return None
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Union
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
from aiogram import Bot, Router, types
|
||||
from aiogram.filters import Command
|
||||
from sqlalchemy import or_, update
|
||||
from sqlalchemy import func, or_, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
@@ -13,7 +13,7 @@ from bot.services.notification_service import NotificationService
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from config.settings import Settings
|
||||
from db.dal import panel_sync_dal, subscription_dal, user_dal
|
||||
from db.models import Subscription
|
||||
from db.models import Subscription, User
|
||||
|
||||
router = Router(name="admin_sync_router")
|
||||
|
||||
@@ -28,6 +28,136 @@ def _normalize_panel_email(value: Optional[str]) -> Optional[str]:
|
||||
return email or None
|
||||
|
||||
|
||||
def _coerce_panel_telegram_id(value: Any) -> Optional[int]:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
logging.warning("Panel user has non-numeric telegramId: %r", value)
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_description(value: Optional[str]) -> str:
|
||||
return "\n".join((value or "").split()).strip()
|
||||
|
||||
|
||||
def _description_matches(current: Optional[str], desired: str) -> bool:
|
||||
return _normalize_description(current) == _normalize_description(desired)
|
||||
|
||||
|
||||
def _datetime_matches(current: Optional[datetime], desired: datetime) -> bool:
|
||||
if current is None:
|
||||
return False
|
||||
current_dt = current if current.tzinfo else current.replace(tzinfo=timezone.utc)
|
||||
desired_dt = desired if desired.tzinfo else desired.replace(tzinfo=timezone.utc)
|
||||
delta = current_dt.astimezone(timezone.utc) - desired_dt.astimezone(timezone.utc)
|
||||
return abs(delta.total_seconds()) < 1
|
||||
|
||||
|
||||
def _subscription_update_delta(
|
||||
subscription: Subscription, desired: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
delta: dict[str, Any] = {}
|
||||
for key, desired_value in desired.items():
|
||||
current_value = getattr(subscription, key)
|
||||
if key == "end_date":
|
||||
if not _datetime_matches(current_value, desired_value):
|
||||
delta[key] = desired_value
|
||||
elif current_value != desired_value:
|
||||
delta[key] = desired_value
|
||||
return delta
|
||||
|
||||
|
||||
async def _prefetch_sync_indexes(
|
||||
session: AsyncSession, panel_users_data: list[dict[str, Any]]
|
||||
) -> dict[str, Any]:
|
||||
telegram_ids: set[int] = set()
|
||||
panel_uuids: set[str] = set()
|
||||
emails: set[str] = set()
|
||||
panel_subscription_uuids: set[str] = set()
|
||||
panel_uuids_by_telegram_id: dict[int, set[str]] = {}
|
||||
|
||||
for panel_user in panel_users_data:
|
||||
telegram_id = _coerce_panel_telegram_id(panel_user.get("telegramId"))
|
||||
panel_uuid = panel_user.get("uuid")
|
||||
if telegram_id:
|
||||
telegram_ids.add(telegram_id)
|
||||
if panel_uuid:
|
||||
panel_uuids_by_telegram_id.setdefault(telegram_id, set()).add(str(panel_uuid))
|
||||
if panel_uuid:
|
||||
panel_uuids.add(panel_uuid)
|
||||
email = _normalize_panel_email(panel_user.get("email"))
|
||||
if email:
|
||||
emails.add(email)
|
||||
panel_subscription_uuid = panel_user.get("subscriptionUuid") or panel_user.get("shortUuid")
|
||||
if panel_subscription_uuid:
|
||||
panel_subscription_uuids.add(panel_subscription_uuid)
|
||||
|
||||
users_by_telegram_id: dict[int, User] = {}
|
||||
users_by_user_id: dict[int, User] = {}
|
||||
users_by_panel_uuid: dict[str, User] = {}
|
||||
users_by_email: dict[str, User] = {}
|
||||
|
||||
user_filters = []
|
||||
if telegram_ids:
|
||||
user_filters.append(User.telegram_id.in_(telegram_ids))
|
||||
user_filters.append(User.user_id.in_(telegram_ids))
|
||||
if panel_uuids:
|
||||
user_filters.append(User.panel_user_uuid.in_(panel_uuids))
|
||||
if emails:
|
||||
user_filters.append(func.lower(User.email).in_(emails))
|
||||
if user_filters:
|
||||
result = await session.execute(select(User).where(or_(*user_filters)))
|
||||
for user in result.scalars().unique().all():
|
||||
if user.telegram_id is not None:
|
||||
users_by_telegram_id[int(user.telegram_id)] = user
|
||||
users_by_user_id[int(user.user_id)] = user
|
||||
if user.panel_user_uuid:
|
||||
users_by_panel_uuid[user.panel_user_uuid] = user
|
||||
if user.email:
|
||||
users_by_email[user.email.strip().lower()] = user
|
||||
|
||||
subscriptions_by_panel_uuid: dict[str, Subscription] = {}
|
||||
if panel_subscription_uuids:
|
||||
result = await session.execute(
|
||||
select(Subscription).where(
|
||||
Subscription.panel_subscription_uuid.in_(panel_subscription_uuids)
|
||||
)
|
||||
)
|
||||
subscriptions_by_panel_uuid = {
|
||||
str(sub.panel_subscription_uuid): sub
|
||||
for sub in result.scalars().unique().all()
|
||||
if sub.panel_subscription_uuid
|
||||
}
|
||||
|
||||
active_subscriptions_by_user_panel: dict[tuple[int, str], Subscription] = {}
|
||||
if panel_uuids:
|
||||
result = await session.execute(
|
||||
select(Subscription)
|
||||
.where(
|
||||
Subscription.panel_user_uuid.in_(panel_uuids),
|
||||
Subscription.is_active.is_(True),
|
||||
Subscription.end_date > datetime.now(timezone.utc),
|
||||
)
|
||||
.order_by(Subscription.end_date.desc())
|
||||
)
|
||||
for sub in result.scalars().unique().all():
|
||||
active_subscriptions_by_user_panel.setdefault(
|
||||
(int(sub.user_id), sub.panel_user_uuid), sub
|
||||
)
|
||||
|
||||
return {
|
||||
"users_by_telegram_id": users_by_telegram_id,
|
||||
"users_by_user_id": users_by_user_id,
|
||||
"users_by_panel_uuid": users_by_panel_uuid,
|
||||
"users_by_email": users_by_email,
|
||||
"subscriptions_by_panel_uuid": subscriptions_by_panel_uuid,
|
||||
"active_subscriptions_by_user_panel": active_subscriptions_by_user_panel,
|
||||
"panel_uuids_by_telegram_id": panel_uuids_by_telegram_id,
|
||||
}
|
||||
|
||||
|
||||
def _extract_lifetime_used_traffic_bytes(panel_user_data: dict) -> Optional[int]:
|
||||
user_traffic = panel_user_data.get("userTraffic") or {}
|
||||
raw_value = (
|
||||
@@ -195,13 +325,23 @@ async def _perform_sync_impl(
|
||||
|
||||
total_panel_users = len(panel_users_data)
|
||||
logging.info(f"Starting sync for {total_panel_users} panel users.")
|
||||
sync_indexes = await _prefetch_sync_indexes(session, panel_users_data)
|
||||
users_by_telegram_id = sync_indexes["users_by_telegram_id"]
|
||||
users_by_user_id = sync_indexes["users_by_user_id"]
|
||||
users_by_panel_uuid = sync_indexes["users_by_panel_uuid"]
|
||||
users_by_email = sync_indexes["users_by_email"]
|
||||
subscriptions_by_panel_uuid = sync_indexes["subscriptions_by_panel_uuid"]
|
||||
active_subscriptions_by_user_panel = sync_indexes["active_subscriptions_by_user_panel"]
|
||||
panel_uuids_by_telegram_id = sync_indexes["panel_uuids_by_telegram_id"]
|
||||
|
||||
for panel_user_dict in panel_users_data:
|
||||
try:
|
||||
panel_records_checked += 1
|
||||
panel_uuid = panel_user_dict.get("uuid")
|
||||
panel_user_dict.get("subscriptionUuid") or panel_user_dict.get("shortUuid")
|
||||
telegram_id_from_panel = panel_user_dict.get("telegramId")
|
||||
telegram_id_from_panel = _coerce_panel_telegram_id(
|
||||
panel_user_dict.get("telegramId")
|
||||
)
|
||||
email_from_panel = _normalize_panel_email(panel_user_dict.get("email"))
|
||||
|
||||
if not panel_uuid:
|
||||
@@ -218,20 +358,16 @@ async def _perform_sync_impl(
|
||||
|
||||
# First, try to find by telegram ID if available
|
||||
if telegram_id_from_panel:
|
||||
existing_user = await user_dal.get_user_by_telegram_id(
|
||||
session, telegram_id_from_panel
|
||||
)
|
||||
if not existing_user:
|
||||
existing_user = await user_dal.get_user_by_id(
|
||||
session, telegram_id_from_panel
|
||||
)
|
||||
existing_user = users_by_telegram_id.get(
|
||||
telegram_id_from_panel
|
||||
) or users_by_user_id.get(telegram_id_from_panel)
|
||||
if existing_user:
|
||||
logging.debug(f"Found user by telegramId {telegram_id_from_panel}")
|
||||
|
||||
# If not found by telegram ID, try to find by panel UUID.
|
||||
# The panel UUID is the strongest local link for subscription sync.
|
||||
if not existing_user:
|
||||
existing_user = await user_dal.get_user_by_panel_uuid(session, panel_uuid)
|
||||
existing_user = users_by_panel_uuid.get(panel_uuid)
|
||||
if existing_user:
|
||||
logging.debug(
|
||||
f"Found user by panel UUID {panel_uuid}, telegramId: {existing_user.user_id}" # noqa: E501
|
||||
@@ -248,7 +384,7 @@ async def _perform_sync_impl(
|
||||
# Finally, fall back to email. This mainly catches panel users that
|
||||
# were first imported as email-only identities.
|
||||
if not existing_user and email_from_panel:
|
||||
existing_user = await user_dal.get_user_by_email(session, email_from_panel)
|
||||
existing_user = users_by_email.get(email_from_panel)
|
||||
if existing_user:
|
||||
logging.debug(f"Found user by email {email_from_panel}")
|
||||
|
||||
@@ -281,6 +417,12 @@ async def _perform_sync_impl(
|
||||
)
|
||||
|
||||
existing_user = new_user
|
||||
users_by_user_id[int(new_user.user_id)] = new_user
|
||||
if new_user.telegram_id is not None:
|
||||
users_by_telegram_id[int(new_user.telegram_id)] = new_user
|
||||
users_by_panel_uuid[panel_uuid] = new_user
|
||||
if email_from_panel:
|
||||
users_by_email[email_from_panel] = new_user
|
||||
|
||||
except Exception as e_create:
|
||||
sync_errors.append(
|
||||
@@ -304,6 +446,9 @@ async def _perform_sync_impl(
|
||||
f"Created new email user {new_user.user_id} from panel sync with UUID {panel_uuid}" # noqa: E501
|
||||
)
|
||||
existing_user = new_user
|
||||
users_by_user_id[int(new_user.user_id)] = new_user
|
||||
users_by_panel_uuid[panel_uuid] = new_user
|
||||
users_by_email[email_from_panel] = new_user
|
||||
except Exception as e_create_email:
|
||||
sync_errors.append(
|
||||
f"Error creating email user {email_from_panel}: {str(e_create_email)}" # noqa: E501
|
||||
@@ -327,10 +472,26 @@ async def _perform_sync_impl(
|
||||
|
||||
# Update panel UUID if different
|
||||
if existing_user.panel_user_uuid != panel_uuid:
|
||||
existing_user.panel_user_uuid = panel_uuid
|
||||
user_was_updated = True
|
||||
users_uuid_updated += 1
|
||||
logging.info(f"Updated panel UUID for user {actual_user_id}: {panel_uuid}")
|
||||
linked_uuid = existing_user.panel_user_uuid
|
||||
linked_uuid_still_present = bool(
|
||||
telegram_id_from_panel
|
||||
and linked_uuid
|
||||
and str(linked_uuid)
|
||||
in panel_uuids_by_telegram_id.get(telegram_id_from_panel, set())
|
||||
)
|
||||
if linked_uuid_still_present:
|
||||
logging.warning(
|
||||
"Sync: duplicate panel users share telegramId %s; keeping local panel UUID %s and skipping duplicate panel UUID %s.", # noqa: E501
|
||||
telegram_id_from_panel,
|
||||
linked_uuid,
|
||||
panel_uuid,
|
||||
)
|
||||
else:
|
||||
existing_user.panel_user_uuid = panel_uuid
|
||||
user_was_updated = True
|
||||
users_uuid_updated += 1
|
||||
users_by_panel_uuid[panel_uuid] = existing_user
|
||||
logging.info(f"Updated panel UUID for user {actual_user_id}: {panel_uuid}")
|
||||
existing_user, email_was_bound = await _bind_panel_email_to_user(
|
||||
session,
|
||||
existing_user=existing_user,
|
||||
@@ -339,9 +500,12 @@ async def _perform_sync_impl(
|
||||
)
|
||||
if email_was_bound:
|
||||
user_was_updated = True
|
||||
if email_from_panel:
|
||||
users_by_email[email_from_panel] = existing_user
|
||||
if telegram_id_from_panel and existing_user.telegram_id != telegram_id_from_panel:
|
||||
existing_user.telegram_id = telegram_id_from_panel
|
||||
user_was_updated = True
|
||||
users_by_telegram_id[telegram_id_from_panel] = existing_user
|
||||
|
||||
lifetime_used = _extract_lifetime_used_traffic_bytes(panel_user_dict)
|
||||
if (
|
||||
@@ -369,7 +533,9 @@ async def _perform_sync_impl(
|
||||
panel_user_dict.get("description") or ""
|
||||
).strip()
|
||||
desired_description = description_text.strip()
|
||||
if desired_description and desired_description != current_panel_description:
|
||||
if desired_description and not _description_matches(
|
||||
current_panel_description, desired_description
|
||||
):
|
||||
await panel_service.update_user_details_on_panel(
|
||||
panel_uuid,
|
||||
{
|
||||
@@ -427,28 +593,31 @@ async def _perform_sync_impl(
|
||||
)
|
||||
|
||||
# Try to find subscription by its panel_subscription_uuid first (idempotent) # noqa: E501
|
||||
existing_sub_by_uuid = (
|
||||
await subscription_dal.get_subscription_by_panel_subscription_uuid(
|
||||
session, subscription_uuid_from_panel
|
||||
)
|
||||
existing_sub_by_uuid = subscriptions_by_panel_uuid.get(
|
||||
subscription_uuid_from_panel
|
||||
)
|
||||
|
||||
if existing_sub_by_uuid:
|
||||
# Atomic update of all relevant fields
|
||||
await subscription_dal.update_subscription(
|
||||
session,
|
||||
existing_sub_by_uuid.subscription_id,
|
||||
{
|
||||
"user_id": actual_user_id,
|
||||
"panel_user_uuid": panel_uuid,
|
||||
"end_date": panel_expire_at,
|
||||
"is_active": panel_status == "ACTIVE",
|
||||
"status_from_panel": panel_status,
|
||||
},
|
||||
update_payload = {
|
||||
"user_id": actual_user_id,
|
||||
"panel_user_uuid": panel_uuid,
|
||||
"end_date": panel_expire_at,
|
||||
"is_active": panel_status == "ACTIVE",
|
||||
"status_from_panel": panel_status,
|
||||
}
|
||||
update_delta = _subscription_update_delta(
|
||||
existing_sub_by_uuid, update_payload
|
||||
)
|
||||
if update_delta:
|
||||
# Atomic update of changed relevant fields
|
||||
await subscription_dal.update_subscription(
|
||||
session,
|
||||
existing_sub_by_uuid.subscription_id,
|
||||
update_delta,
|
||||
)
|
||||
subscriptions_updated += 1
|
||||
user_was_updated = True
|
||||
subscriptions_synced_count += 1
|
||||
subscriptions_updated += 1
|
||||
user_was_updated = True
|
||||
logging.debug(
|
||||
f"Synced existing subscription {existing_sub_by_uuid.subscription_id} " # noqa: E501
|
||||
f"for user {actual_user_id}: expires {panel_expire_at}, status {panel_status}" # noqa: E501
|
||||
@@ -471,6 +640,15 @@ async def _perform_sync_impl(
|
||||
created_sub = await subscription_dal.upsert_subscription(
|
||||
session, sub_payload
|
||||
)
|
||||
subscriptions_by_panel_uuid[subscription_uuid_from_panel] = (
|
||||
created_sub
|
||||
)
|
||||
if created_sub.is_active and created_sub.end_date > datetime.now(
|
||||
timezone.utc
|
||||
):
|
||||
active_subscriptions_by_user_panel[
|
||||
(int(created_sub.user_id), created_sub.panel_user_uuid)
|
||||
] = created_sub
|
||||
subscriptions_synced_count += 1
|
||||
subscriptions_created += 1
|
||||
user_was_updated = True
|
||||
@@ -480,22 +658,27 @@ async def _perform_sync_impl(
|
||||
)
|
||||
else:
|
||||
# No subscription UUID from panel: only update an already active subscription for this user/panel UUID # noqa: E501
|
||||
active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, actual_user_id, panel_uuid
|
||||
active_sub = active_subscriptions_by_user_panel.get(
|
||||
(actual_user_id, panel_uuid)
|
||||
)
|
||||
if active_sub:
|
||||
await subscription_dal.update_subscription(
|
||||
session,
|
||||
active_sub.subscription_id,
|
||||
{
|
||||
"end_date": panel_expire_at,
|
||||
"is_active": panel_status == "ACTIVE",
|
||||
"status_from_panel": panel_status,
|
||||
},
|
||||
update_payload = {
|
||||
"end_date": panel_expire_at,
|
||||
"is_active": panel_status == "ACTIVE",
|
||||
"status_from_panel": panel_status,
|
||||
}
|
||||
update_delta = _subscription_update_delta(
|
||||
active_sub, update_payload
|
||||
)
|
||||
if update_delta:
|
||||
await subscription_dal.update_subscription(
|
||||
session,
|
||||
active_sub.subscription_id,
|
||||
update_delta,
|
||||
)
|
||||
subscriptions_updated += 1
|
||||
user_was_updated = True
|
||||
subscriptions_synced_count += 1
|
||||
subscriptions_updated += 1
|
||||
user_was_updated = True
|
||||
logging.debug(
|
||||
f"Updated active subscription {active_sub.subscription_id} "
|
||||
f"for user {actual_user_id}: expires {panel_expire_at}, status {panel_status}" # noqa: E501
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Awaitable, Callable, Dict, Optional
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
@@ -6,9 +7,13 @@ from aiogram.types import Update
|
||||
from aiogram.types import User as TgUser
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.infra.redis import cache_get_json, cache_set_json, redis_key
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username, username_for_display
|
||||
from config.settings import Settings
|
||||
from db.dal import user_dal
|
||||
|
||||
_LOCAL_PROFILE_SYNC_CHECKS: Dict[int, float] = {}
|
||||
|
||||
|
||||
class ProfileSyncMiddleware(BaseMiddleware):
|
||||
async def __call__(
|
||||
@@ -19,8 +24,12 @@ class ProfileSyncMiddleware(BaseMiddleware):
|
||||
) -> Any:
|
||||
session: AsyncSession = data.get("session")
|
||||
tg_user: Optional[TgUser] = data.get("event_from_user")
|
||||
settings: Optional[Settings] = data.get("settings")
|
||||
|
||||
if session and tg_user:
|
||||
if settings and await _profile_sync_recently_checked(settings, int(tg_user.id)):
|
||||
return await handler(event, data)
|
||||
|
||||
try:
|
||||
db_user = await user_dal.get_user_by_telegram_id(session, tg_user.id)
|
||||
if not db_user:
|
||||
@@ -79,5 +88,42 @@ class ProfileSyncMiddleware(BaseMiddleware):
|
||||
f"ProfileSyncMiddleware: Failed to sync profile for user {getattr(tg_user, 'id', 'N/A')}: {e}", # noqa: E501
|
||||
exc_info=True,
|
||||
)
|
||||
finally:
|
||||
if settings:
|
||||
await _mark_profile_sync_checked(settings, int(tg_user.id))
|
||||
|
||||
return await handler(event, data)
|
||||
|
||||
|
||||
async def _profile_sync_recently_checked(settings: Settings, telegram_id: int) -> bool:
|
||||
ttl_seconds = int(getattr(settings, "PROFILE_SYNC_CACHE_TTL_SECONDS", 900) or 0)
|
||||
if ttl_seconds <= 0:
|
||||
return False
|
||||
|
||||
now = time.monotonic()
|
||||
expires_at = _LOCAL_PROFILE_SYNC_CHECKS.get(telegram_id)
|
||||
if expires_at and expires_at > now:
|
||||
return True
|
||||
|
||||
key = redis_key(settings, "cache", "profile-sync", telegram_id)
|
||||
try:
|
||||
cached = await cache_get_json(settings, key)
|
||||
except Exception:
|
||||
cached = None
|
||||
if cached:
|
||||
_LOCAL_PROFILE_SYNC_CHECKS[telegram_id] = now + ttl_seconds
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def _mark_profile_sync_checked(settings: Settings, telegram_id: int) -> None:
|
||||
ttl_seconds = int(getattr(settings, "PROFILE_SYNC_CACHE_TTL_SECONDS", 900) or 0)
|
||||
if ttl_seconds <= 0:
|
||||
return
|
||||
|
||||
_LOCAL_PROFILE_SYNC_CHECKS[telegram_id] = time.monotonic() + ttl_seconds
|
||||
key = redis_key(settings, "cache", "profile-sync", telegram_id)
|
||||
try:
|
||||
await cache_set_json(settings, key, {"checked": True}, ttl_seconds)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -41,6 +41,24 @@ class PanelApiService:
|
||||
settings=settings,
|
||||
namespace="panel:hosts",
|
||||
)
|
||||
self._users_cache: AsyncTTLCache = AsyncTTLCache(
|
||||
ttl_seconds=max(0, int(getattr(settings, "PANEL_USER_CACHE_TTL_SECONDS", 5) or 0)),
|
||||
settings=settings,
|
||||
namespace="panel:users",
|
||||
)
|
||||
self._devices_cache: AsyncTTLCache = AsyncTTLCache(
|
||||
ttl_seconds=max(0, int(getattr(settings, "PANEL_DEVICES_CACHE_TTL_SECONDS", 5) or 0)),
|
||||
settings=settings,
|
||||
namespace="panel:devices",
|
||||
)
|
||||
self._all_users_cache: AsyncTTLCache = AsyncTTLCache(
|
||||
ttl_seconds=max(
|
||||
0,
|
||||
int(getattr(settings, "PANEL_ALL_USERS_CACHE_TTL_SECONDS", 5) or 0),
|
||||
),
|
||||
settings=settings,
|
||||
namespace="panel:all_users",
|
||||
)
|
||||
|
||||
async def __aenter__(self):
|
||||
"""Context manager entry"""
|
||||
@@ -226,6 +244,18 @@ class PanelApiService:
|
||||
|
||||
async def get_all_panel_users(
|
||||
self, page_size: int = 100, log_responses: bool = False
|
||||
) -> Optional[List[Dict[str, Any]]]:
|
||||
if log_responses or page_size != 100 or self._all_users_cache.ttl_seconds <= 0:
|
||||
return await self._get_all_panel_users_uncached(
|
||||
page_size=page_size, log_responses=log_responses
|
||||
)
|
||||
return await self._all_users_cache.get_or_load(
|
||||
f"page_size:{page_size}",
|
||||
lambda: self._get_all_panel_users_uncached(page_size=page_size, log_responses=False),
|
||||
)
|
||||
|
||||
async def _get_all_panel_users_uncached(
|
||||
self, page_size: int = 100, log_responses: bool = False
|
||||
) -> Optional[List[Dict[str, Any]]]:
|
||||
all_users = []
|
||||
start_offset = 0
|
||||
@@ -253,6 +283,16 @@ class PanelApiService:
|
||||
|
||||
async def get_user_by_uuid(
|
||||
self, user_uuid: str, log_response: bool = False
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if log_response or self._users_cache.ttl_seconds <= 0:
|
||||
return await self._get_user_by_uuid_uncached(user_uuid, log_response=log_response)
|
||||
return await self._users_cache.get_or_load(
|
||||
f"uuid:{user_uuid}",
|
||||
lambda: self._get_user_by_uuid_uncached(user_uuid, log_response=False),
|
||||
)
|
||||
|
||||
async def _get_user_by_uuid_uncached(
|
||||
self, user_uuid: str, log_response: bool = False
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
endpoint = f"/users/{user_uuid}"
|
||||
full_response = await self._request("GET", endpoint, log_full_response=log_response)
|
||||
@@ -422,6 +462,7 @@ class PanelApiService:
|
||||
"POST", "/users", json=payload, log_full_response=log_response
|
||||
)
|
||||
if response and not response.get("error") and "response" in response:
|
||||
self._invalidate_all_users_cache()
|
||||
logging.info(
|
||||
f"Panel user '{username_on_panel}' created successfully (UUID: {response.get('response', {}).get('uuid')})." # noqa: E501
|
||||
)
|
||||
@@ -443,6 +484,8 @@ class PanelApiService:
|
||||
)
|
||||
if full_response and not full_response.get("error") and "response" in full_response:
|
||||
logging.debug("User %s details updated on panel.", user_uuid)
|
||||
self._invalidate_user_cache(user_uuid)
|
||||
self._invalidate_all_users_cache()
|
||||
return full_response.get("response")
|
||||
|
||||
logging.error(
|
||||
@@ -458,6 +501,8 @@ class PanelApiService:
|
||||
response_data = await self._request("POST", endpoint, log_full_response=log_response)
|
||||
|
||||
if response_data and not response_data.get("error") and "response" in response_data:
|
||||
self._invalidate_user_cache(user_uuid)
|
||||
self._invalidate_all_users_cache()
|
||||
actual_status = response_data.get("response", {}).get("status")
|
||||
expected_status = "ACTIVE" if enable else "DISABLED"
|
||||
if actual_status == expected_status:
|
||||
@@ -494,11 +539,17 @@ class PanelApiService:
|
||||
logging.info(
|
||||
f"Panel user {user_uuid} already absent (errorCode {error_code}). Treating as deleted." # noqa: E501
|
||||
)
|
||||
self._invalidate_user_cache(user_uuid)
|
||||
self._invalidate_devices_cache(user_uuid)
|
||||
self._invalidate_all_users_cache()
|
||||
return True
|
||||
logging.error(f"Failed to delete user {user_uuid} on panel. Response: {response_data}")
|
||||
return False
|
||||
|
||||
logging.info(f"Panel user {user_uuid} deleted successfully.")
|
||||
self._invalidate_user_cache(user_uuid)
|
||||
self._invalidate_devices_cache(user_uuid)
|
||||
self._invalidate_all_users_cache()
|
||||
return True
|
||||
|
||||
async def get_subscription_link(
|
||||
@@ -513,6 +564,14 @@ class PanelApiService:
|
||||
return base_sub_url
|
||||
|
||||
async def get_user_devices(self, user_uuid: str) -> Optional[List[Dict[str, Any]]]:
|
||||
if self._devices_cache.ttl_seconds <= 0:
|
||||
return await self._get_user_devices_uncached(user_uuid)
|
||||
return await self._devices_cache.get_or_load(
|
||||
f"user:{user_uuid}",
|
||||
lambda: self._get_user_devices_uncached(user_uuid),
|
||||
)
|
||||
|
||||
async def _get_user_devices_uncached(self, user_uuid: str) -> Optional[List[Dict[str, Any]]]:
|
||||
endpoint = f"/hwid/devices/{user_uuid}"
|
||||
response_data = await self._request("GET", endpoint, log_full_response=False)
|
||||
if response_data and not response_data.get("error") and "response" in response_data:
|
||||
@@ -525,6 +584,7 @@ class PanelApiService:
|
||||
payload = {"userUuid": user_uuid, "hwid": hwid}
|
||||
response_data = await self._request("POST", endpoint, json=payload, log_full_response=False)
|
||||
if response_data and not response_data.get("error") and "response" in response_data:
|
||||
self._invalidate_devices_cache(user_uuid)
|
||||
return True
|
||||
logging.error(
|
||||
f"Failed to disconnect device {hwid} for user {user_uuid}. Payload: {payload}, Response: {response_data}" # noqa: E501
|
||||
@@ -629,6 +689,19 @@ class PanelApiService:
|
||||
def _invalidate_squad_caches(self) -> None:
|
||||
self._squads_cache.invalidate()
|
||||
|
||||
def _invalidate_user_cache(self, user_uuid: Optional[str]) -> None:
|
||||
if not user_uuid:
|
||||
return
|
||||
self._users_cache.invalidate(f"uuid:{user_uuid}")
|
||||
|
||||
def _invalidate_all_users_cache(self) -> None:
|
||||
self._all_users_cache.invalidate()
|
||||
|
||||
def _invalidate_devices_cache(self, user_uuid: Optional[str]) -> None:
|
||||
if not user_uuid:
|
||||
return
|
||||
self._devices_cache.invalidate(f"user:{user_uuid}")
|
||||
|
||||
async def get_internal_squads(self) -> Optional[List[Dict[str, Any]]]:
|
||||
return await self._squads_cache.get_or_load("list", self._get_internal_squads_uncached)
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ PREMIUM_WARNING_DEPLETED_LEVEL = PREMIUM_WARNING_LEVEL_OFFSET + 100
|
||||
# to avoid an N+1 serial chain to the Remnawave panel each tick.
|
||||
TARIFF_WORKER_BATCH_SIZE = 50
|
||||
TARIFF_WORKER_PANEL_CONCURRENCY = 10
|
||||
TARIFF_WORKER_BULK_PANEL_FETCH_THRESHOLD = 200
|
||||
TARIFF_WORKER_BULK_PANEL_FETCH_THRESHOLD = 50
|
||||
|
||||
|
||||
class TariffTrafficWorker:
|
||||
|
||||
@@ -96,6 +96,11 @@ class Settings(BaseSettings):
|
||||
REDIS_KEY_PREFIX: str = Field(default="remnawave-tg-shop")
|
||||
WEBAPP_ME_CACHE_TTL_SECONDS: int = Field(default=15)
|
||||
WEBAPP_DEVICES_CACHE_TTL_SECONDS: int = Field(default=5)
|
||||
PANEL_USER_CACHE_TTL_SECONDS: int = Field(default=5)
|
||||
PANEL_DEVICES_CACHE_TTL_SECONDS: int = Field(default=5)
|
||||
PANEL_ALL_USERS_CACHE_TTL_SECONDS: int = Field(default=5)
|
||||
ADMIN_PANEL_STATS_CACHE_TTL_SECONDS: int = Field(default=15)
|
||||
PROFILE_SYNC_CACHE_TTL_SECONDS: int = Field(default=900)
|
||||
WEBAPP_RATE_LIMIT_TTL_SECONDS: int = Field(default=60)
|
||||
WEBAPP_RATE_LIMIT_MAX_REQUESTS: int = Field(default=30)
|
||||
WEBHOOK_QUEUE_NAME: str = Field(default="webhook-events")
|
||||
@@ -103,7 +108,7 @@ class Settings(BaseSettings):
|
||||
WORKER_PANEL_SYNC_INTERVAL_SECONDS: int = Field(default=900)
|
||||
TARIFF_WORKER_LOCK_TTL_SECONDS: int = Field(default=240)
|
||||
TARIFF_WORKER_TICK_SECONDS: int = Field(default=300)
|
||||
TARIFF_WORKER_BULK_PANEL_FETCH_THRESHOLD: int = Field(default=200)
|
||||
TARIFF_WORKER_BULK_PANEL_FETCH_THRESHOLD: int = Field(default=50)
|
||||
|
||||
DEFAULT_LANGUAGE: str = Field(default="ru")
|
||||
DEFAULT_CURRENCY_SYMBOL: str = Field(default="RUB")
|
||||
|
||||
Reference in New Issue
Block a user