diff --git a/app/cabinet/routes/admin_users.py b/app/cabinet/routes/admin_users.py index dc03502a..ab47d61b 100644 --- a/app/cabinet/routes/admin_users.py +++ b/app/cabinet/routes/admin_users.py @@ -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( diff --git a/app/cabinet/routes/auth.py b/app/cabinet/routes/auth.py index fe8c4964..6202f44a 100644 --- a/app/cabinet/routes/auth.py +++ b/app/cabinet/routes/auth.py @@ -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, diff --git a/app/cabinet/routes/balance.py b/app/cabinet/routes/balance.py index 689d861b..1a048d82 100644 --- a/app/cabinet/routes/balance.py +++ b/app/cabinet/routes/balance.py @@ -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, ) diff --git a/app/services/payment_method_config_service.py b/app/services/payment_method_config_service.py index f748d239..6348c244 100644 --- a/app/services/payment_method_config_service.py +++ b/app/services/payment_method_config_service.py @@ -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', ] diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py index 680d0c19..9d066642 100644 --- a/app/services/remnawave_service.py +++ b/app/services/remnawave_service.py @@ -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) diff --git a/app/utils/timezone.py b/app/utils/timezone.py index 715c7522..c3e720a9 100644 --- a/app/utils/timezone.py +++ b/app/utils/timezone.py @@ -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."""