This commit is contained in:
gy9vin
2026-01-30 09:35:23 +03:00
6 changed files with 86 additions and 76 deletions
+9 -18
View File
@@ -1,7 +1,7 @@
"""Admin routes for managing users in cabinet."""
import logging
from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import Integer, and_, func, or_, select
@@ -32,6 +32,7 @@ from app.database.models import (
User,
UserStatus,
)
from app.utils.timezone import panel_datetime_to_naive_utc
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..schemas.users import (
@@ -1372,12 +1373,8 @@ async def get_user_sync_status(
bot_end_utc = (
bot_sub_end_date.replace(tzinfo=None) if bot_sub_end_date.tzinfo else bot_sub_end_date
)
# Panel dates might be timezone-aware, convert to UTC first
if panel_expire_at.tzinfo:
panel_end_utc = panel_expire_at.astimezone(UTC).replace(tzinfo=None)
else:
# Panel might return naive datetime in MSK (UTC+3), try both interpretations
panel_end_utc = panel_expire_at
# Panel returns local time with misleading +00:00 offset
panel_end_utc = panel_datetime_to_naive_utc(panel_expire_at)
diff_seconds = abs((bot_end_utc - panel_end_utc).total_seconds())
# Allow for timezone offset (3 hours = MSK) and small sync delays
@@ -1508,7 +1505,7 @@ async def sync_user_from_panel(
short_uuid=panel_user.short_uuid,
username=panel_user.username,
status=panel_user.status.value if panel_user.status else None,
expire_at=panel_user.expire_at,
expire_at=panel_datetime_to_naive_utc(panel_user.expire_at) if panel_user.expire_at else None,
traffic_limit_gb=panel_user.traffic_limit_bytes / (1024**3) if panel_user.traffic_limit_bytes else 0,
traffic_used_gb=panel_user.used_traffic_bytes / (1024**3) if panel_user.used_traffic_bytes else 0,
device_limit=panel_user.hwid_device_limit or 1,
@@ -1527,11 +1524,8 @@ async def sync_user_from_panel(
# Update end date (normalize timezone)
if panel_user.expire_at:
# Convert panel expire_at to naive UTC for storage
if panel_user.expire_at.tzinfo:
panel_expire_utc = panel_user.expire_at.astimezone(UTC).replace(tzinfo=None)
else:
panel_expire_utc = panel_user.expire_at
# Panel returns local time with misleading +00:00 offset
panel_expire_utc = panel_datetime_to_naive_utc(panel_user.expire_at)
sub_end_naive = (
sub.end_date.replace(tzinfo=None) if sub.end_date and sub.end_date.tzinfo else sub.end_date
@@ -1602,11 +1596,8 @@ async def sync_user_from_panel(
panel_traffic_limit = (
int(panel_user.traffic_limit_bytes / (1024**3)) if panel_user.traffic_limit_bytes else 100
)
# Normalize panel expire date for calculation
if panel_user.expire_at.tzinfo:
panel_expire_naive = panel_user.expire_at.astimezone(UTC).replace(tzinfo=None)
else:
panel_expire_naive = panel_user.expire_at
# Panel returns local time with misleading +00:00 offset
panel_expire_naive = panel_datetime_to_naive_utc(panel_user.expire_at)
days_remaining = max(1, (panel_expire_naive - datetime.utcnow()).days)
new_sub = await create_paid_subscription(
+10 -17
View File
@@ -19,6 +19,7 @@ from app.database.crud.user import (
)
from app.database.models import CabinetRefreshToken, User
from app.services.referral_service import process_referral_registration
from app.utils.timezone import panel_datetime_to_naive_utc
from ..auth import (
create_access_token,
@@ -162,8 +163,8 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
existing_sub = await get_subscription_by_user_id(db, user.id)
# Parse panel data
expire_at = panel_user.expire_at
# Parse panel data — panel returns local time with misleading +00:00 offset
expire_at = panel_datetime_to_naive_utc(panel_user.expire_at)
traffic_limit_gb = panel_user.traffic_limit_bytes // (1024**3) if panel_user.traffic_limit_bytes > 0 else 0
traffic_used_gb = panel_user.used_traffic_bytes / (1024**3) if panel_user.used_traffic_bytes > 0 else 0
@@ -173,11 +174,8 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
# Device limit from panel
device_limit = panel_user.hwid_device_limit or 1
# Determine status - use timezone-aware datetime for comparison
current_time = datetime.now(UTC)
# Make expire_at timezone-aware if it's naive
if expire_at.tzinfo is None:
expire_at = expire_at.replace(tzinfo=UTC)
# Determine status — expire_at is now naive UTC
current_time = datetime.now(UTC).replace(tzinfo=None)
if panel_user.status.value == 'ACTIVE' and expire_at > current_time:
sub_status = SubscriptionStatus.ACTIVE
@@ -187,10 +185,8 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
sub_status = SubscriptionStatus.DISABLED
if existing_sub:
# Update existing subscription
# Convert to naive datetime for database storage
end_date_naive = expire_at.replace(tzinfo=None) if expire_at.tzinfo else expire_at
existing_sub.end_date = end_date_naive
# Update existing subscription (expire_at already naive UTC)
existing_sub.end_date = expire_at
existing_sub.traffic_limit_gb = traffic_limit_gb
existing_sub.traffic_used_gb = traffic_used_gb
existing_sub.status = sub_status.value
@@ -204,14 +200,11 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
f'Updated subscription for email user {user.email}, squads: {connected_squads}, devices: {device_limit}'
)
else:
# Create new subscription
# Convert current_time to naive for database storage if needed
start_date_naive = current_time.replace(tzinfo=None)
end_date_naive = expire_at.replace(tzinfo=None) if expire_at.tzinfo else expire_at
# Create new subscription (expire_at and current_time already naive UTC)
new_sub = Subscription(
user_id=user.id,
start_date=start_date_naive,
end_date=end_date_naive,
start_date=current_time,
end_date=expire_at,
traffic_limit_gb=traffic_limit_gb,
traffic_used_gb=traffic_used_gb,
status=sub_status.value,
+1
View File
@@ -754,6 +754,7 @@ async def create_topup(
user_id=user.id,
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(request.amount_kopeks),
email=getattr(user, 'email', None),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
)
@@ -109,6 +109,13 @@ def _get_method_defaults() -> dict:
{'id': 'sbp', 'name': 'СБП'},
],
},
'kassa_ai': {
'default_display_name': settings.get_kassa_ai_display_name(),
'is_configured': settings.is_kassa_ai_enabled(),
'default_min': settings.KASSA_AI_MIN_AMOUNT_KOPEKS,
'default_max': settings.KASSA_AI_MAX_AMOUNT_KOPEKS,
'available_sub_options': None,
},
}
@@ -146,6 +153,7 @@ DEFAULT_METHOD_ORDER = [
'wata',
'freekassa',
'cloudpayments',
'kassa_ai',
]
+46 -41
View File
@@ -253,10 +253,9 @@ class RemnaWaveService:
parsed_date = datetime.fromisoformat(cleaned_date)
if parsed_date.tzinfo is not None:
localized = parsed_date.astimezone(self._panel_timezone)
else:
localized = parsed_date.replace(tzinfo=self._panel_timezone)
# Убираем tzinfo и интерпретируем время как локальное время панели
naive_date = parsed_date.replace(tzinfo=None)
localized = naive_date.replace(tzinfo=self._panel_timezone)
utc_normalized = localized.astimezone(self._utc_timezone).replace(tzinfo=None)
@@ -268,27 +267,34 @@ class RemnaWaveService:
return self._now_utc() + timedelta(days=30)
def _safe_expire_at_for_panel(self, expire_at: datetime | None) -> datetime:
"""Гарантирует, что дата окончания не в прошлом для панели."""
"""Гарантирует, что дата окончания не в прошлом для панели.
Принимает naive UTC datetime, возвращает naive datetime в таймзоне панели.
"""
now = self._now_utc()
minimum_expire = now + timedelta(minutes=1)
if not expire_at:
return minimum_expire
result = minimum_expire
else:
normalized_expire = expire_at
if normalized_expire.tzinfo is not None:
normalized_expire = normalized_expire.replace(tzinfo=None)
normalized_expire = expire_at
if normalized_expire.tzinfo is not None:
normalized_expire = normalized_expire.replace(tzinfo=None)
if normalized_expire < minimum_expire:
logger.debug(
'⚙️ Коррекция даты истечения (%s) до минимально допустимой (%s) для панели',
normalized_expire,
minimum_expire,
)
result = minimum_expire
else:
result = normalized_expire
if normalized_expire < minimum_expire:
logger.debug(
'⚙️ Коррекция даты истечения (%s) до минимально допустимой (%s) для панели',
normalized_expire,
minimum_expire,
)
return minimum_expire
return normalized_expire
# Конвертируем из naive UTC в локальное время панели (naive)
utc_aware = result.replace(tzinfo=self._utc_timezone)
return utc_aware.astimezone(self._panel_timezone).replace(tzinfo=None)
def _safe_panel_expire_date(self, panel_user: dict[str, Any]) -> datetime:
"""Парсит дату окончания подписки пользователя панели для сравнения."""
@@ -1139,7 +1145,7 @@ class RemnaWaveService:
'status': user_obj.status.value,
'telegramId': user_obj.telegram_id,
'email': user_obj.email, # Email для синхронизации email-only пользователей
'expireAt': user_obj.expire_at.isoformat() + 'Z',
'expireAt': user_obj.expire_at.replace(tzinfo=None).isoformat(),
'trafficLimitBytes': user_obj.traffic_limit_bytes,
'usedTrafficBytes': user_obj.used_traffic_bytes,
'hwidDeviceLimit': user_obj.hwid_device_limit,
@@ -1283,20 +1289,24 @@ class RemnaWaveService:
logger.info(f'🔄 Обновлены поля {updated_fields} для пользователя {telegram_id}')
await db.flush() # Сохраняем изменения без коммита
# Проверяем, есть ли у пользователя подписка, загруженная с пользователем
if hasattr(db_user, 'subscription') and db_user.subscription:
# Используем уже загруженную подписку
await self._update_subscription_from_panel_data(db, db_user, panel_user)
else:
# Если подписки нет, создаем новую
await self._create_subscription_from_panel_data(db, db_user, panel_user)
# Обновляем UUID ДО операций с подпиской, чтобы избежать
# greenlet_spawn ошибки при доступе к атрибутам после flush
_, uuid_mutation = self._ensure_user_remnawave_uuid(
db_user,
panel_user.get('uuid'),
bot_users_by_uuid,
)
# Используем async запрос вместо доступа к relationship,
# чтобы избежать lazy-load в async контексте
from app.database.crud.subscription import get_subscription_by_user_id as _get_sub
existing_sub = await _get_sub(db, db_user.id)
if existing_sub:
await self._update_subscription_from_panel_data(db, db_user, panel_user)
else:
await self._create_subscription_from_panel_data(db, db_user, panel_user)
stats['updated'] += 1
logger.debug(f'✅ Обновлён пользователь {telegram_id}')
@@ -1369,8 +1379,12 @@ class RemnaWaveService:
if panel_uuid and not db_user.remnawave_uuid:
db_user.remnawave_uuid = panel_uuid
# Обновляем или создаем подписку
if hasattr(db_user, 'subscription') and db_user.subscription:
# Используем async запрос вместо доступа к relationship,
# чтобы избежать lazy-load (greenlet_spawn) в async контексте
from app.database.crud.subscription import get_subscription_by_user_id as _get_sub_email
existing_sub = await _get_sub_email(db, db_user.id)
if existing_sub:
await self._update_subscription_from_panel_data(db, db_user, panel_user)
else:
await self._create_subscription_from_panel_data(db, db_user, panel_user)
@@ -1637,18 +1651,9 @@ class RemnaWaveService:
from app.database.crud.subscription import get_subscription_by_user_id
from app.database.models import SubscriptionStatus
# Сначала пытаемся использовать уже загруженную подписку, если она есть
subscription = None
try:
# Проверяем, что подписка уже загружена (была загружена через selectinload)
if hasattr(user, 'subscription') and user.subscription:
subscription = user.subscription
else:
# В противном случае, получаем подписку через CRUD метод
subscription = await get_subscription_by_user_id(db, user.id)
except:
# Если не удалось получить подписку через ленивую загрузку
subscription = await get_subscription_by_user_id(db, user.id)
# Всегда используем async CRUD запрос для получения подписки,
# чтобы избежать lazy-load (greenlet_spawn) в async контексте
subscription = await get_subscription_by_user_id(db, user.id)
if not subscription:
await self._create_subscription_from_panel_data(db, user, panel_user)
+12
View File
@@ -34,6 +34,18 @@ def get_local_timezone() -> ZoneInfo:
return ZoneInfo('UTC')
def panel_datetime_to_naive_utc(dt: datetime) -> datetime:
"""Convert a panel datetime to naive UTC.
Panel API returns local time with a misleading UTC offset (+00:00 / Z).
This strips the offset, interprets the raw value as panel-local time,
then converts to naive UTC for database storage.
"""
naive = dt.replace(tzinfo=None)
localized = naive.replace(tzinfo=get_local_timezone())
return localized.astimezone(ZoneInfo('UTC')).replace(tzinfo=None)
def to_local_datetime(dt: datetime | None) -> datetime | None:
"""Convert a datetime value to the configured local timezone."""