From e94b93d0c10b4e61d7750ca47e1b2f888f5873ed Mon Sep 17 00:00:00 2001 From: Fringg Date: Tue, 10 Feb 2026 20:35:42 +0300 Subject: [PATCH 01/16] fix: handle nullable traffic_limit_gb and end_date in subscription model Add None-safety guards to Subscription model properties (is_active, is_expired, should_be_expired, actual_status, days_left, traffic_used_percent) and pricing handler comparisons to prevent TypeError when nullable columns contain None values. --- app/database/models.py | 27 ++++++++++++++++++--------- app/handlers/subscription/pricing.py | 9 +++++---- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/app/database/models.py b/app/database/models.py index fc265fcf..8403ef37 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -1159,17 +1159,25 @@ class Subscription(Base): @property def is_active(self) -> bool: current_time = datetime.utcnow() - return self.status == SubscriptionStatus.ACTIVE.value and self.end_date > current_time + return ( + self.status == SubscriptionStatus.ACTIVE.value + and self.end_date is not None + and self.end_date > current_time + ) @property def is_expired(self) -> bool: """Проверяет, истёк ли срок подписки""" - return self.end_date <= datetime.utcnow() + return self.end_date is not None and self.end_date <= datetime.utcnow() @property def should_be_expired(self) -> bool: current_time = datetime.utcnow() - return self.status == SubscriptionStatus.ACTIVE.value and self.end_date <= current_time + return ( + self.status == SubscriptionStatus.ACTIVE.value + and self.end_date is not None + and self.end_date <= current_time + ) @property def actual_status(self) -> str: @@ -1182,12 +1190,12 @@ class Subscription(Base): return 'disabled' if self.status == SubscriptionStatus.ACTIVE.value: - if self.end_date <= current_time: + if self.end_date is None or self.end_date <= current_time: return 'expired' return 'active' if self.status == SubscriptionStatus.TRIAL.value: - if self.end_date <= current_time: + if self.end_date is None or self.end_date <= current_time: return 'expired' return 'trial' @@ -1230,6 +1238,8 @@ class Subscription(Base): @property def days_left(self) -> int: + if self.end_date is None: + return 0 current_time = datetime.utcnow() if self.end_date <= current_time: return 0 @@ -1255,11 +1265,10 @@ class Subscription(Base): @property def traffic_used_percent(self) -> float: - if self.traffic_limit_gb == 0: + if not self.traffic_limit_gb: return 0.0 - if self.traffic_limit_gb > 0: - return min((self.traffic_used_gb / self.traffic_limit_gb) * 100, 100.0) - return 0.0 + used = self.traffic_used_gb or 0.0 + return min((used / self.traffic_limit_gb) * 100, 100.0) def extend_subscription(self, days: int): if self.end_date > datetime.utcnow(): diff --git a/app/handlers/subscription/pricing.py b/app/handlers/subscription/pricing.py index 8213a0db..7d935dee 100644 --- a/app/handlers/subscription/pricing.py +++ b/app/handlers/subscription/pricing.py @@ -404,15 +404,16 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess status_text = '⌛ Истекла' type_text = 'Платная подписка' - if subscription.traffic_limit_gb == 0: + traffic_limit = subscription.traffic_limit_gb or 0 + if traffic_limit == 0: if settings.is_traffic_fixed(): traffic_text = '∞ Безлимитный' else: traffic_text = '∞ Безлимитный' elif settings.is_traffic_fixed(): - traffic_text = f'{subscription.traffic_limit_gb} ГБ' + traffic_text = f'{traffic_limit} ГБ' else: - traffic_text = f'{subscription.traffic_limit_gb} ГБ' + traffic_text = f'{traffic_limit} ГБ' subscription_cost = await get_subscription_cost(subscription, db) @@ -444,7 +445,7 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess info_text += f'\n💰 Стоимость подписки в месяц: {texts.format_price(subscription_cost)}' # Отображаем докупленный трафик - if subscription.traffic_limit_gb > 0: # Только для лимитированных тарифов + if (subscription.traffic_limit_gb or 0) > 0: # Только для лимитированных тарифов from datetime import datetime from sqlalchemy import select as sql_select From e43a8d6ce4c40a7212bf90644f82da109717bdcb Mon Sep 17 00:00:00 2001 From: Fringg Date: Tue, 10 Feb 2026 23:11:48 +0300 Subject: [PATCH 02/16] fix: downgrade Telegram timeout errors to warning in monitoring service Add TelegramNetworkError handling before generic Exception catch in all notification methods to prevent timeout errors from generating error reports in chat. Timeouts are transient network issues, not bugs. --- app/services/monitoring_service.py | 56 +++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index a7f2aa6b..4df729fa 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -5,7 +5,7 @@ from pathlib import Path from typing import Any from aiogram.enums import ChatMemberStatus -from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError +from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramNetworkError from sqlalchemy import and_, or_, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -1286,6 +1286,13 @@ class MonitoringService: exc, ) return False + except TelegramNetworkError as e: + logger.warning( + 'Таймаут отправки уведомления об истечении подписки пользователю %s: %s', + user.telegram_id, + e, + ) + return False except Exception as e: logger.error( 'Ошибка отправки уведомления об истечении подписки пользователю %s: %s', @@ -1335,6 +1342,13 @@ class MonitoringService: exc, ) return False + except TelegramNetworkError as e: + logger.warning( + 'Таймаут отправки уведомления об окончании тестовой подписки пользователю %s: %s', + user.telegram_id, + e, + ) + return False except Exception as e: logger.error( 'Ошибка отправки уведомления об окончании тестовой подписки пользователю %s: %s', @@ -1410,6 +1424,13 @@ class MonitoringService: exc, ) return False + except TelegramNetworkError as e: + logger.warning( + 'Таймаут отправки уведомления об отсутствии подключения пользователю %s: %s', + user.telegram_id, + e, + ) + return False except Exception as e: logger.error( 'Ошибка отправки уведомления об отсутствии подключения пользователю %s: %s', @@ -1473,6 +1494,13 @@ class MonitoringService: exc, ) return False + except TelegramNetworkError as error: + logger.warning( + 'Таймаут отправки уведомления об отписке от канала пользователю %s: %s', + user.telegram_id, + error, + ) + return False except Exception as error: logger.error( 'Ошибка отправки уведомления об отписке от канала пользователю %s: %s', @@ -1537,6 +1565,13 @@ class MonitoringService: exc, ) return False + except TelegramNetworkError as e: + logger.warning( + 'Таймаут отправки напоминания об истекшей подписке пользователю %s: %s', + user.telegram_id, + e, + ) + return False except Exception as e: logger.error( 'Ошибка отправки напоминания об истекшей подписке пользователю %s: %s', @@ -1629,6 +1664,13 @@ class MonitoringService: exc, ) return False + except TelegramNetworkError as e: + logger.warning( + 'Таймаут отправки скидочного уведомления пользователю %s: %s', + user.telegram_id, + e, + ) + return False except Exception as e: logger.error( 'Ошибка отправки скидочного уведомления пользователю %s: %s', @@ -1653,6 +1695,12 @@ class MonitoringService: user.telegram_id, exc, ) + except TelegramNetworkError as e: + logger.warning( + 'Таймаут отправки уведомления об автоплатеже пользователю %s: %s', + user.telegram_id, + e, + ) except Exception as e: logger.error( 'Ошибка отправки уведомления об автоплатеже пользователю %s: %s', @@ -1690,6 +1738,12 @@ class MonitoringService: user.telegram_id, exc, ) + except TelegramNetworkError as e: + logger.warning( + 'Таймаут отправки уведомления о неудачном автоплатеже пользователю %s: %s', + user.telegram_id, + e, + ) except Exception as e: logger.error( 'Ошибка отправки уведомления о неудачном автоплатеже пользователю %s: %s', From d58a80f3eaa64a6fc899e10b3b14584fb7fc18a9 Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 11 Feb 2026 01:18:58 +0300 Subject: [PATCH 03/16] fix: handle StaleDataError in webhook when user already deleted When a user is deleted via cabinet, RemnaWave sends user.disabled webhook but the subscription row is already cascade-deleted. This caused StaleDataError on commit + PendingRollbackError when logging user.id. Save user_id before handler call and catch StaleDataError as warning. --- app/services/remnawave_webhook_service.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/app/services/remnawave_webhook_service.py b/app/services/remnawave_webhook_service.py index ff7f7f6e..79387cc4 100644 --- a/app/services/remnawave_webhook_service.py +++ b/app/services/remnawave_webhook_service.py @@ -17,6 +17,7 @@ from typing import Any from aiogram import Bot from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm.exc import StaleDataError from app.database.crud.subscription import ( deactivate_subscription, @@ -164,11 +165,23 @@ class RemnaWaveWebhookService: ) return False + user_id = user.id try: await handler(db, user, subscription, data) return True + except StaleDataError: + logger.warning( + 'RemnaWave webhook %s: entity already deleted for user %s (concurrent deletion)', + event_name, + user_id, + ) + try: + await db.rollback() + except Exception: + pass + return True except Exception: - logger.exception('Error processing RemnaWave webhook event %s for user %s', event_name, user.id) + logger.exception('Error processing RemnaWave webhook event %s for user %s', event_name, user_id) try: await db.rollback() except Exception: From a1ffd5bda6b63145104ce750835d8e6492d781dc Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 11 Feb 2026 01:39:39 +0300 Subject: [PATCH 04/16] fix: prevent cascading greenlet errors after sync rollback After db.rollback() all ORM objects expire. Subsequent attribute access triggers lazy load in async context causing greenlet_spawn errors for every remaining user. Break the sync loop after rollback instead of continuing with a corrupted session. Also downgrade TelegramNetworkError to warning in channel_checker. --- app/middlewares/channel_checker.py | 5 ++++- app/services/remnawave_service.py | 13 +++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/app/middlewares/channel_checker.py b/app/middlewares/channel_checker.py index 505b1299..c46f0105 100644 --- a/app/middlewares/channel_checker.py +++ b/app/middlewares/channel_checker.py @@ -6,7 +6,7 @@ from typing import Any import redis.asyncio as aioredis from aiogram import BaseMiddleware, Bot, types from aiogram.enums import ChatMemberStatus -from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError +from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramNetworkError from aiogram.fsm.context import FSMContext from aiogram.types import CallbackQuery, Message, TelegramObject, Update @@ -197,6 +197,9 @@ class ChannelCheckerMiddleware(BaseMiddleware): logger.error(f'❌ Ошибка запроса к каналу {channel_id}: {e}') await self._capture_start_payload(state, event, bot) return await self._deny_message(event, bot, channel_link, channel_id) + except TelegramNetworkError as e: + logger.warning(f'⚠️ Таймаут при проверке подписки на канал: {e}') + return await handler(event, data) except Exception as e: logger.error(f'❌ Неожиданная ошибка при проверке подписки: {e}') return await handler(event, data) diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py index 36d35ad3..08709fa4 100644 --- a/app/services/remnawave_service.py +++ b/app/services/remnawave_service.py @@ -1335,9 +1335,18 @@ class RemnaWaveService: pending_uuid_mutations.clear() try: await db.rollback() # Выполняем rollback при ошибке - except: + except Exception: pass - continue + # After rollback all ORM objects in the session are expired. + # Accessing their attributes triggers a lazy load which fails + # in async context (greenlet_spawn error). Break the loop to + # prevent cascading failures for every remaining user. + logger.warning( + '⚠️ Сессия повреждена после rollback, прерываем обработку (обработано %d/%d пользователей)', + i + 1, + len(unique_panel_users), + ) + break else: if uuid_mutation and uuid_mutation.has_changes(): From be1da976e14a35e6cca01a7fca7529c55c1a208b Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 11 Feb 2026 01:40:49 +0300 Subject: [PATCH 05/16] fix: ignore 'message is not modified' on privacy policy decline User clicking Decline twice produced the same edit_text causing TelegramBadRequest. Silently ignore it and remove pointless retry. --- app/handlers/start.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/app/handlers/start.py b/app/handlers/start.py index 3118a703..d4b9e829 100644 --- a/app/handlers/start.py +++ b/app/handlers/start.py @@ -931,14 +931,11 @@ async def process_privacy_policy_accept(callback: types.CallbackQuery, state: FS await callback.message.edit_text( privacy_policy_required_text, reply_markup=get_privacy_policy_keyboard(language) ) + except TelegramBadRequest as e: + if 'message is not modified' not in str(e): + logger.warning(f'Ошибка при показе сообщения об отклонении политики: {e}') except Exception as e: - logger.error(f'Ошибка при показе сообщения об отклонении политики конфиденциальности: {e}') - try: - await callback.message.edit_text( - privacy_policy_required_text, reply_markup=get_privacy_policy_keyboard(language) - ) - except: - pass + logger.warning(f'Ошибка при показе сообщения об отклонении политики: {e}') logger.info(f'✅ Политика конфиденциальности обработана для пользователя {callback.from_user.id}') From eaf3a07579729031030308d77f61a5227b796c02 Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 11 Feb 2026 01:58:40 +0300 Subject: [PATCH 06/16] fix: use callback fallback when MINIAPP_CUSTOM_URL is not set Only consider MINIAPP_CUSTOM_URL for miniapp buttons, not the purchase-only MINIAPP_PURCHASE_URL which cannot display subscription info and loads indefinitely. When no custom URL is configured, fall back to regular callback_data so the bot shows subscription natively. --- app/utils/miniapp_buttons.py | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/app/utils/miniapp_buttons.py b/app/utils/miniapp_buttons.py index c3b5592f..c5c1dd1f 100644 --- a/app/utils/miniapp_buttons.py +++ b/app/utils/miniapp_buttons.py @@ -4,32 +4,28 @@ from aiogram.types import InlineKeyboardButton from app.config import settings -DEFAULT_UNAVAILABLE_CALLBACK = 'menu_profile_unavailable' - - def build_miniapp_or_callback_button( text: str, *, callback_data: str, - unavailable_callback: str = DEFAULT_UNAVAILABLE_CALLBACK, ) -> InlineKeyboardButton: - """Create a button that opens the miniapp in text menu mode. + """Create a button that opens the miniapp or falls back to a callback. - When the simplified text menu mode is enabled we should avoid exposing - deep bot flows and redirect the user to the configured miniapp instead. - If the miniapp URL is missing we fall back to a safe callback that shows - an alert about the unavailable profile rather than opening disabled - sections of the bot. + In text menu mode, if ``MINIAPP_CUSTOM_URL`` is configured the button + opens the full cabinet miniapp. Otherwise (or outside text menu mode) + the regular ``callback_data`` is used so the user stays in the bot. + + Only ``MINIAPP_CUSTOM_URL`` is considered here — the purchase-only URL + (``MINIAPP_PURCHASE_URL``) is intentionally excluded because it cannot + display subscription details and would load indefinitely. """ if settings.is_text_main_menu_mode(): - miniapp_url = settings.get_main_menu_miniapp_url() + miniapp_url = (settings.MINIAPP_CUSTOM_URL or '').strip() if miniapp_url: return InlineKeyboardButton( text=text, web_app=types.WebAppInfo(url=miniapp_url), ) - safe_callback = unavailable_callback or DEFAULT_UNAVAILABLE_CALLBACK - return InlineKeyboardButton(text=text, callback_data=safe_callback) return InlineKeyboardButton(text=text, callback_data=callback_data) From 19dabf38512ae0c2121108d0b92fc8f384292484 Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 11 Feb 2026 02:30:40 +0300 Subject: [PATCH 07/16] fix: allow purchase when recalculated price is lower than cached MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only block purchase when the price increased (user would overpay). When a promo discount activates between viewing price and confirming, the recalculated price is lower — allow the purchase at the new price instead of forcing the user to restart the checkout flow. --- app/handlers/subscription/purchase.py | 43 +++++++++++++++------------ 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index c8ffc466..723d0d4f 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -2420,26 +2420,31 @@ async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_ promo_offer_discount_percent = 0 # Валидация: проверяем что cached_total_price соответствует ожидаемой финальной цене - # Допускаем небольшое расхождение из-за округления (до 5%) - price_difference = abs(final_price - cached_total_price) - max_allowed_difference = max(500, int(final_price * 0.05)) # 5% или минимум 5₽ - - if price_difference > max_allowed_difference: - # Слишком большое расхождение - блокируем покупку - logger.error( - f'Критическое расхождение цены для пользователя {db_user.telegram_id}: ' - f'кэш={cached_total_price / 100}₽, пересчет={final_price / 100}₽, ' - f'разница={price_difference / 100}₽ (>{max_allowed_difference / 100}₽). ' - f'Покупка заблокирована.' - ) - await callback.answer('Цена изменилась. Пожалуйста, начните оформление заново.', show_alert=True) - return - if price_difference > 100: # допуск 1₽ - # Небольшое расхождение - логируем предупреждение но продолжаем - logger.warning( - f'Расхождение цены для пользователя {db_user.telegram_id}: ' + # Блокируем только если цена ВЫРОСЛА (пользователь переплатит). + # Если цена снизилась (промо-скидка активировалась) — разрешаем покупку по новой цене. + price_difference = final_price - cached_total_price + if price_difference > 0: + max_allowed_increase = max(500, int(final_price * 0.05)) # 5% или минимум 5₽ + if price_difference > max_allowed_increase: + logger.error( + f'Цена выросла для пользователя {db_user.telegram_id}: ' + f'кэш={cached_total_price / 100}₽, пересчет={final_price / 100}₽, ' + f'разница=+{price_difference / 100}₽ (>{max_allowed_increase / 100}₽). ' + f'Покупка заблокирована.' + ) + await callback.answer('Цена изменилась. Пожалуйста, начните оформление заново.', show_alert=True) + return + if price_difference > 100: # допуск 1₽ + logger.warning( + f'Небольшой рост цены для пользователя {db_user.telegram_id}: ' + f'кэш={cached_total_price / 100}₽, пересчет={final_price / 100}₽. ' + f'Используем пересчитанную цену.' + ) + elif price_difference < -100: # цена снизилась более чем на 1₽ + logger.info( + f'Цена снизилась для пользователя {db_user.telegram_id}: ' f'кэш={cached_total_price / 100}₽, пересчет={final_price / 100}₽. ' - f'Используем пересчитанную цену.' + f'Применяем новую цену.' ) # Используем пересчитанную цену From 02e40bd6f7ef8e653cae53ccd127f2f79009e0d4 Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 11 Feb 2026 03:35:16 +0300 Subject: [PATCH 08/16] fix: expand backup coverage to all 68 models and harden restore - Add 37 missing models to backup (payment providers, polls, contests, wheel, FAQ, promo offers, webhooks, configs, menu buttons, etc.) - Add tariff_promo_groups and payment_method_promo_groups association tables - Replace hardcoded association restore with generic handler - Fix transaction atomicity: flush instead of commit in inner methods, remove inner rollback calls, single commit/rollback in outer handler - Fix composite PK support for UserPromoGroup (was only detecting first PK) - Fix duplicate insert bug when clear_existing=True and record already exists - Add cabinet_refresh_tokens to clear list, fix support_audit_logs deletion order - Add Time column parsing for ReferralContest.daily_summary_time - Security: tarfile filter='data', path traversal protection in _restore_files and delete_backup, os.sep in startswith checks --- app/services/backup_service.py | 265 ++++++++++++++++++++++++++------- 1 file changed, 209 insertions(+), 56 deletions(-) diff --git a/app/services/backup_service.py b/app/services/backup_service.py index 0c32c342..d44066a8 100644 --- a/app/services/backup_service.py +++ b/app/services/backup_service.py @@ -7,7 +7,7 @@ import shutil import tarfile import tempfile from dataclasses import asdict, dataclass -from datetime import datetime, timedelta +from datetime import datetime, time as dt_time, timedelta from pathlib import Path from typing import Any @@ -24,14 +24,41 @@ from app.database.models import ( AdvertisingCampaign, AdvertisingCampaignRegistration, BroadcastHistory, + ButtonClickLog, + CloudPaymentsPayment, + ContestAttempt, + ContestRound, + ContestTemplate, CryptoBotPayment, DiscountOffer, + FaqPage, + FaqSetting, + FreekassaPayment, + HeleketPayment, + KassaAiPayment, + MainMenuButton, + MenuLayoutHistory, MonitoringLog, MulenPayPayment, Pal24Payment, + PaymentMethodConfig, + PinnedMessage, + PlategaPayment, + Poll, + PollAnswer, + PollOption, + PollQuestion, + PollResponse, + PrivacyPolicy, PromoCode, PromoCodeUse, PromoGroup, + PromoOfferLog, + PromoOfferTemplate, + PublicOffer, + ReferralContest, + ReferralContestEvent, + ReferralContestVirtualParticipant, ReferralEarning, SentNotification, ServerSquad, @@ -39,19 +66,33 @@ from app.database.models import ( Squad, Subscription, SubscriptionConversion, + SubscriptionEvent, SubscriptionServer, + SubscriptionTemporaryAccess, SupportAuditLog, SystemSetting, Tariff, Ticket, TicketMessage, + TicketNotification, + TrafficPurchase, Transaction, User, UserMessage, + UserPromoGroup, + WataPayment, WebApiToken, + Webhook, + WebhookDelivery, WelcomeText, + WheelConfig, + WheelPrize, + WheelSpin, + WithdrawalRequest, YooKassaPayment, + payment_method_promo_groups, server_squad_promo_groups, + tariff_promo_groups, ) @@ -122,6 +163,53 @@ class BackupService: TicketMessage, SupportAuditLog, WebApiToken, + # --- Payment providers (FK: users, transactions) --- + HeleketPayment, + WataPayment, + PlategaPayment, + CloudPaymentsPayment, + FreekassaPayment, + KassaAiPayment, + # --- Settings/content --- + PaymentMethodConfig, + PrivacyPolicy, + PublicOffer, + FaqSetting, + FaqPage, + PinnedMessage, + MainMenuButton, + MenuLayoutHistory, + # --- User data (FK: users, promo_groups, subscriptions) --- + UserPromoGroup, + TrafficPurchase, + SubscriptionEvent, + SubscriptionTemporaryAccess, + PromoOfferTemplate, + PromoOfferLog, + # --- Referral/contests (FK: users) --- + WithdrawalRequest, + ReferralContest, + ReferralContestEvent, + ReferralContestVirtualParticipant, + ContestTemplate, + ContestRound, + ContestAttempt, + # --- Polls (FK chain: polls -> questions -> options -> answers) --- + Poll, + PollQuestion, + PollOption, + PollResponse, + PollAnswer, + # --- Webhooks --- + Webhook, + WebhookDelivery, + # --- Wheel (FK chain: configs -> prizes -> spins) --- + WheelConfig, + WheelPrize, + WheelSpin, + # --- Support --- + TicketNotification, + ButtonClickLog, ] self.backup_models_ordered = self._base_backup_models.copy() @@ -131,6 +219,8 @@ class BackupService: self.association_tables = { 'server_squad_promo_groups': server_squad_promo_groups, + 'tariff_promo_groups': tariff_promo_groups, + 'payment_method_promo_groups': payment_method_promo_groups, } def _load_settings(self) -> BackupSettings: @@ -538,7 +628,7 @@ class BackupService: except Exception as exc: logger.error('Ошибка при экспорте данных: %s', exc) - raise exc + raise async def _collect_files(self, staging_dir: Path, include_logs: bool) -> list[dict[str, Any]]: files_info: list[dict[str, Any]] = [] @@ -623,7 +713,7 @@ class BackupService: mode = 'r:gz' if backup_path.suffixes and backup_path.suffixes[-1] == '.gz' else 'r' with tarfile.open(backup_path, mode) as tar: - tar.extractall(temp_path) + tar.extractall(temp_path, filter='data') metadata_path = temp_path / 'metadata.json' if not metadata_path.exists(): @@ -785,20 +875,31 @@ class BackupService: logger.info('📁 Снимок директории data восстановлен') async def _restore_files(self, files_info: list[dict[str, Any]], temp_path: Path): + allowed_base = self.data_dir.resolve() + for file_info in files_info: relative_path = file_info.get('relative_path') target_path = Path(file_info.get('path', '')) if not relative_path or not target_path: continue - source_file = temp_path / relative_path + target_resolved = target_path.resolve() + if not str(target_resolved).startswith(str(allowed_base) + os.sep) and target_resolved != allowed_base: + logger.warning('Заблокирована запись за пределами data_dir: %s', target_path) + continue + + source_file = (temp_path / relative_path).resolve() + if not str(source_file).startswith(str(temp_path.resolve()) + os.sep): + logger.warning('Path traversal в relative_path: %s', relative_path) + continue + if not source_file.exists(): logger.warning('Файл %s отсутствует в архиве', relative_path) continue - target_path.parent.mkdir(parents=True, exist_ok=True) - await asyncio.to_thread(shutil.copy2, source_file, target_path) - logger.info('📁 Файл %s восстановлен', target_path) + target_resolved.parent.mkdir(parents=True, exist_ok=True) + await asyncio.to_thread(shutil.copy2, source_file, target_resolved) + logger.info('📁 Файл %s восстановлен', target_resolved) async def _restore_database_payload( self, @@ -914,7 +1015,7 @@ class BackupService: except Exception as exc: await db.rollback() logger.error('Ошибка при восстановлении: %s', exc) - raise exc + raise return restored_tables, restored_records @@ -994,10 +1095,9 @@ class BackupService: except Exception as e: logger.error(f'Ошибка при восстановлении пользователя: {e}') - await db.rollback() - raise e + raise - await db.commit() + await db.flush() logger.info('✅ Пользователи без реферальных связей восстановлены') async def _update_user_referrals(self, db: AsyncSession, backup_data: dict): @@ -1031,7 +1131,7 @@ class BackupService: logger.error(f'Ошибка при обновлении реферальной связи: {e}') continue - await db.commit() + await db.flush() logger.info('✅ Реферальные связи обновлены') def _process_record_data(self, record_data: dict, model, table_name: str) -> dict: @@ -1058,6 +1158,12 @@ class BackupService: except (ValueError, TypeError) as e: logger.warning(f'Не удалось парсить дату {value} для поля {key}: {e}') processed_data[key] = datetime.utcnow() + elif column_type_str == 'TIME' and isinstance(value, str): + try: + processed_data[key] = dt_time.fromisoformat(value) + except (ValueError, TypeError) as e: + logger.warning(f'Не удалось парсить время {value} для поля {key}: {e}') + processed_data[key] = dt_time(hour=12, minute=0) elif ('BOOLEAN' in column_type_str or 'BOOL' in column_type_str) and isinstance(value, str): processed_data[key] = value.lower() in ('true', '1', 'yes', 'on') elif ( @@ -1089,11 +1195,8 @@ class BackupService: return processed_data - def _get_primary_key_column(self, model) -> str | None: - for col in model.__table__.columns: - if col.primary_key: - return col.name - return None + def _get_primary_key_columns(self, model) -> list[str]: + return [col.name for col in model.__table__.columns if col.primary_key] async def _export_association_tables(self, db: AsyncSession) -> dict[str, list[dict[str, Any]]]: association_data: dict[str, list[dict[str, Any]]] = {} @@ -1119,63 +1222,60 @@ class BackupService: restored_tables = 0 restored_records = 0 - if 'server_squad_promo_groups' in association_data: - restored = await self._restore_server_squad_promo_groups( - db, association_data['server_squad_promo_groups'], clear_existing + for table_name, table_obj in self.association_tables.items(): + if table_name not in association_data: + continue + col_names = [col.name for col in table_obj.columns] + restored = await self._restore_association_table( + db, table_obj, table_name, association_data[table_name], clear_existing, col_names ) restored_tables += 1 restored_records += restored return restored_tables, restored_records - async def _restore_server_squad_promo_groups( - self, db: AsyncSession, records: list[dict[str, Any]], clear_existing: bool + async def _restore_association_table( + self, + db: AsyncSession, + table_obj, + table_name: str, + records: list[dict[str, Any]], + clear_existing: bool, + col_names: list[str], ) -> int: if not records: return 0 if clear_existing: - await db.execute(server_squad_promo_groups.delete()) + await db.execute(table_obj.delete()) restored = 0 for record in records: - server_id = record.get('server_squad_id') - promo_id = record.get('promo_group_id') + values = {col: record.get(col) for col in col_names} - if server_id is None or promo_id is None: - logger.warning('Пропущена некорректная запись server_squad_promo_groups: %s', record) + if any(v is None for v in values.values()): + logger.warning('Пропущена некорректная запись %s: %s', table_name, record) continue try: + first_col = col_names[0] exists_stmt = ( - select(server_squad_promo_groups.c.server_squad_id) - .where( - server_squad_promo_groups.c.server_squad_id == server_id, - server_squad_promo_groups.c.promo_group_id == promo_id, - ) + select(table_obj.c[first_col]) + .where(*[table_obj.c[col] == values[col] for col in col_names]) .limit(1) ) existing = await db.execute(exists_stmt) if existing.scalar_one_or_none() is not None: - logger.debug( - 'Запись server_squad_promo_groups (%s, %s) уже существует', - server_id, - promo_id, - ) + logger.debug('Запись %s %s уже существует', table_name, values) continue - await db.execute( - server_squad_promo_groups.insert().values(server_squad_id=server_id, promo_group_id=promo_id) - ) + await db.execute(table_obj.insert().values(**values)) restored += 1 except Exception as e: - logger.error( - 'Ошибка при восстановлении связи server_squad_promo_groups (%s, %s): %s', server_id, promo_id, e - ) - await db.rollback() - raise e + logger.error('Ошибка при восстановлении связи %s %s: %s', table_name, values, e) + raise return restored @@ -1205,17 +1305,16 @@ class BackupService: logger.warning(f'⚠️ Тариф {tariff_id} не найден, устанавливаем tariff_id=NULL для подписки') processed_data['tariff_id'] = None - primary_key_col = self._get_primary_key_column(model) + pk_cols = self._get_primary_key_columns(model) - if primary_key_col and primary_key_col in processed_data: - existing_record = await db.execute( - select(model).where(getattr(model, primary_key_col) == processed_data[primary_key_col]) - ) + if pk_cols and all(col in processed_data for col in pk_cols): + where_clause = [getattr(model, col) == processed_data[col] for col in pk_cols] + existing_record = await db.execute(select(model).where(*where_clause)) existing = existing_record.scalar_one_or_none() - if existing and not clear_existing: + if existing: for key, value in processed_data.items(): - if key != primary_key_col: + if key not in pk_cols: setattr(existing, key, value) else: instance = model(**processed_data) @@ -1229,17 +1328,69 @@ class BackupService: except Exception as e: logger.error(f'Ошибка восстановления записи в {table_name}: {e}') logger.error(f'Проблемные данные: {record_data}') - await db.rollback() - raise e + raise return restored_count async def _clear_database_tables(self, db: AsyncSession, backup_data: dict[str, Any] | None = None): tables_order = [ + # --- Association tables (no FK deps on them, safe to delete first) --- 'server_squad_promo_groups', + 'tariff_promo_groups', + 'payment_method_promo_groups', + # --- Polls (child -> parent order) --- + 'poll_answers', + 'poll_responses', + 'poll_options', + 'poll_questions', + 'polls', + # --- Wheel (child -> parent) --- + 'wheel_spins', + 'wheel_prizes', + 'wheel_configs', + # --- Contests (child -> parent) --- + 'contest_attempts', + 'contest_rounds', + 'contest_templates', + 'referral_contest_virtual_participants', + 'referral_contest_events', + 'referral_contests', + # --- Webhooks --- + 'webhook_deliveries', + 'webhooks', + # --- Promo offers --- + 'promo_offer_logs', + 'promo_offer_templates', + 'subscription_temporary_access', + # --- User engagement --- + 'subscription_events', + 'traffic_purchases', + 'user_promo_groups', + 'withdrawal_requests', + # --- Support extras --- + 'ticket_notifications', + 'button_click_logs', + # --- Payment providers --- + 'heleket_payments', + 'wata_payments', + 'platega_payments', + 'cloudpayments_payments', + 'freekassa_payments', + 'kassa_ai_payments', + # --- Content/config --- + 'pinned_messages', + 'main_menu_buttons', + 'menu_layout_history', + 'faq_pages', + 'faq_settings', + 'privacy_policies', + 'public_offers', + 'payment_method_configs', + # --- Original tables (preserved order) --- + 'support_audit_logs', 'ticket_messages', 'tickets', - 'support_audit_logs', + 'cabinet_refresh_tokens', 'advertising_campaign_registrations', 'advertising_campaigns', 'subscription_servers', @@ -1408,7 +1559,9 @@ class BackupService: async def delete_backup(self, backup_filename: str) -> tuple[bool, str]: try: - backup_path = self.backup_dir / backup_filename + backup_path = (self.backup_dir / backup_filename).resolve() + if not str(backup_path).startswith(str(self.backup_dir.resolve()) + os.sep): + return False, '❌ Недопустимое имя файла бекапа' if not backup_path.exists(): return False, f'❌ Файл бекапа не найден: {backup_filename}' From 3a680b41b0124848572809d187cab720e1db8506 Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 11 Feb 2026 03:47:30 +0300 Subject: [PATCH 09/16] fix: suppress 'message is not modified' error in updates panel - Remove dangling version_info['repo_url'] expression - Handle 'message is not modified' in all three update handlers to prevent error screen on repeated button clicks --- app/handlers/admin/updates.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/handlers/admin/updates.py b/app/handlers/admin/updates.py index af1ed887..a9a57fe9 100644 --- a/app/handlers/admin/updates.py +++ b/app/handlers/admin/updates.py @@ -69,6 +69,9 @@ async def show_updates_menu(callback: types.CallbackQuery, db_user: User, db: As await callback.answer() except Exception as e: + if 'message is not modified' in str(e).lower(): + logger.debug('📝 Сообщение не изменено в show_updates_menu') + return logger.error(f'Ошибка показа меню обновлений: {e}') await callback.answer('❌ Ошибка загрузки меню обновлений', show_alert=True) @@ -118,6 +121,9 @@ async def check_updates(callback: types.CallbackQuery, db_user: User, db: AsyncS await callback.message.edit_text(message, reply_markup=keyboard, parse_mode='HTML') except Exception as e: + if 'message is not modified' in str(e).lower(): + logger.debug('📝 Сообщение не изменено в check_updates') + return logger.error(f'Ошибка проверки обновлений: {e}') await callback.message.edit_text( f'❌ ОШИБКА ПРОВЕРКИ ОБНОВЛЕНИЙ\n\n' @@ -142,7 +148,6 @@ async def show_version_info(callback: types.CallbackQuery, db_user: User, db: As newer_releases = version_info['newer_releases'] has_updates = version_info['has_updates'] last_check = version_info['last_check'] - version_info['repo_url'] current_info = '📦 ТЕКУЩАЯ ВЕРСИЯ\n\n' @@ -198,6 +203,9 @@ async def show_version_info(callback: types.CallbackQuery, db_user: User, db: As ) except Exception as e: + if 'message is not modified' in str(e).lower(): + logger.debug('📝 Сообщение не изменено в show_version_info') + return logger.error(f'Ошибка получения информации о версиях: {e}') await callback.message.edit_text( f'❌ ОШИБКА ЗАГРУЗКИ\n\n' From b0fd38d60c22247a0086c570665b92c73a060f2f Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 11 Feb 2026 04:02:09 +0300 Subject: [PATCH 10/16] fix: clear subscription data when user deleted from Remnawave panel Previously only status was set to expired and remnawave_uuid cleared. Now also clears subscription_url, subscription_crypto_link, remnawave_short_uuid, and connected_squads so the bot correctly shows no active subscription after panel deletion. --- app/services/remnawave_webhook_service.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/services/remnawave_webhook_service.py b/app/services/remnawave_webhook_service.py index 79387cc4..c94d4264 100644 --- a/app/services/remnawave_webhook_service.py +++ b/app/services/remnawave_webhook_service.py @@ -581,8 +581,13 @@ class RemnaWaveWebhookService: subscription.id, user.id, ) - else: - await db.commit() + + # Clear subscription data — panel user no longer exists + subscription.subscription_url = None + subscription.subscription_crypto_link = None + subscription.remnawave_short_uuid = None + subscription.connected_squads = None + await db.commit() # Clear remnawave linkage if user.remnawave_uuid: From 20948869902dc570681b05709ac8d51996330a6e Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 11 Feb 2026 04:09:39 +0300 Subject: [PATCH 11/16] fix: address review issues in backup, updates, and webhook handlers - backup: add DATE column parsing in restore, use is_file() in delete_backup - updates: add missing callback.answer() in show_updates_menu early return - webhook: add server counter decrement and SubscriptionServer cleanup on user deletion, use single commit --- app/handlers/admin/updates.py | 1 + app/services/backup_service.py | 10 ++++++++-- app/services/remnawave_webhook_service.py | 18 ++++++++++++++---- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/app/handlers/admin/updates.py b/app/handlers/admin/updates.py index a9a57fe9..62637e1b 100644 --- a/app/handlers/admin/updates.py +++ b/app/handlers/admin/updates.py @@ -71,6 +71,7 @@ async def show_updates_menu(callback: types.CallbackQuery, db_user: User, db: As except Exception as e: if 'message is not modified' in str(e).lower(): logger.debug('📝 Сообщение не изменено в show_updates_menu') + await callback.answer() return logger.error(f'Ошибка показа меню обновлений: {e}') await callback.answer('❌ Ошибка загрузки меню обновлений', show_alert=True) diff --git a/app/services/backup_service.py b/app/services/backup_service.py index d44066a8..c6f90fbc 100644 --- a/app/services/backup_service.py +++ b/app/services/backup_service.py @@ -7,7 +7,7 @@ import shutil import tarfile import tempfile from dataclasses import asdict, dataclass -from datetime import datetime, time as dt_time, timedelta +from datetime import date as dt_date, datetime, time as dt_time, timedelta from pathlib import Path from typing import Any @@ -1164,6 +1164,12 @@ class BackupService: except (ValueError, TypeError) as e: logger.warning(f'Не удалось парсить время {value} для поля {key}: {e}') processed_data[key] = dt_time(hour=12, minute=0) + elif column_type_str == 'DATE' and isinstance(value, str): + try: + processed_data[key] = dt_date.fromisoformat(value) + except (ValueError, TypeError) as e: + logger.warning(f'Не удалось парсить дату {value} для поля {key}: {e}') + processed_data[key] = None elif ('BOOLEAN' in column_type_str or 'BOOL' in column_type_str) and isinstance(value, str): processed_data[key] = value.lower() in ('true', '1', 'yes', 'on') elif ( @@ -1563,7 +1569,7 @@ class BackupService: if not str(backup_path).startswith(str(self.backup_dir.resolve()) + os.sep): return False, '❌ Недопустимое имя файла бекапа' - if not backup_path.exists(): + if not backup_path.is_file(): return False, f'❌ Файл бекапа не найден: {backup_filename}' backup_path.unlink() diff --git a/app/services/remnawave_webhook_service.py b/app/services/remnawave_webhook_service.py index c94d4264..9a62149d 100644 --- a/app/services/remnawave_webhook_service.py +++ b/app/services/remnawave_webhook_service.py @@ -16,18 +16,20 @@ from typing import Any from aiogram import Bot from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup +from sqlalchemy import delete from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm.exc import StaleDataError from app.database.crud.subscription import ( deactivate_subscription, + decrement_subscription_server_counts, expire_subscription, get_subscription_by_user_id, reactivate_subscription, update_subscription_usage, ) from app.database.crud.user import get_user_by_remnawave_uuid, get_user_by_telegram_id -from app.database.models import Subscription, SubscriptionStatus, User +from app.database.models import Subscription, SubscriptionServer, SubscriptionStatus, User from app.localization.texts import get_texts from app.services.admin_notification_service import AdminNotificationService from app.services.notification_delivery_service import NotificationType, notification_delivery_service @@ -574,8 +576,12 @@ class RemnaWaveWebhookService: ) -> None: if subscription: self._stamp_webhook_update(subscription) + + # Decrement server counters BEFORE clearing connected_squads + await decrement_subscription_server_counts(db, subscription) + if subscription.status != SubscriptionStatus.EXPIRED.value: - await expire_subscription(db, subscription) + subscription.status = SubscriptionStatus.EXPIRED.value logger.info( 'Webhook: subscription %s marked expired (user deleted in panel) for user %s', subscription.id, @@ -587,12 +593,16 @@ class RemnaWaveWebhookService: subscription.subscription_crypto_link = None subscription.remnawave_short_uuid = None subscription.connected_squads = None - await db.commit() + subscription.updated_at = datetime.now(UTC).replace(tzinfo=None) + + # Remove SubscriptionServer link rows + await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == subscription.id)) # Clear remnawave linkage if user.remnawave_uuid: user.remnawave_uuid = None - await db.commit() + + await db.commit() await self._notify_user(user, 'WEBHOOK_SUB_DELETED', reply_markup=self._get_renew_keyboard(user)) From 6cec024e46ef9177cb59aa81590953c9a75d81bb Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 11 Feb 2026 04:15:50 +0300 Subject: [PATCH 12/16] fix: use flush instead of commit in server counter functions add_user_to_servers and remove_user_from_servers were calling db.commit() internally, breaking transaction atomicity for all callers that perform additional operations afterward. Changed to db.flush() so the caller controls the commit boundary. --- app/database/crud/server_squad.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/app/database/crud/server_squad.py b/app/database/crud/server_squad.py index caedf03f..553214dd 100644 --- a/app/database/crud/server_squad.py +++ b/app/database/crud/server_squad.py @@ -740,14 +740,13 @@ async def add_user_to_servers(db: AsyncSession, server_squad_ids: list[int]) -> .values(current_users=ServerSquad.current_users + 1) ) - await db.commit() + await db.flush() logger.info(f'✅ Увеличен счетчик пользователей для серверов: {server_squad_ids}') return True except Exception as e: logger.error(f'Ошибка увеличения счетчика пользователей: {e}') - await db.rollback() - return False + raise async def remove_user_from_servers(db: AsyncSession, server_squad_ids: list[int]) -> bool: @@ -759,14 +758,13 @@ async def remove_user_from_servers(db: AsyncSession, server_squad_ids: list[int] .values(current_users=func.greatest(ServerSquad.current_users - 1, 0)) ) - await db.commit() + await db.flush() logger.info(f'✅ Уменьшен счетчик пользователей для серверов: {server_squad_ids}') return True except Exception as e: logger.error(f'Ошибка уменьшения счетчика пользователей: {e}') - await db.rollback() - return False + raise async def get_server_ids_by_uuids(db: AsyncSession, squad_uuids: list[str]) -> list[int]: From b167ed3dd1c6e6239db2bdbb8424bcb1fb7715d9 Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 11 Feb 2026 04:38:08 +0300 Subject: [PATCH 13/16] fix: preserve purchased traffic when extending same tariff extend_subscription was unconditionally resetting purchased_traffic_gb and deleting TrafficPurchase records whenever traffic_limit_gb was passed, even when extending the same tariff (not changing). Now only resets on actual tariff change (is_tariff_change=True), preserving purchased traffic on same-tariff extensions. --- app/database/crud/subscription.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index c6239162..b76b2cf5 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -440,17 +440,24 @@ async def extend_subscription( if traffic_limit_gb is not None: old_traffic = subscription.traffic_limit_gb - subscription.traffic_limit_gb = traffic_limit_gb subscription.traffic_used_gb = 0.0 - # Сбрасываем все докупки трафика при смене тарифа - from sqlalchemy import delete as sql_delete - from app.database.models import TrafficPurchase + if is_tariff_change: + # При СМЕНЕ тарифа сбрасываем все докупки трафика + subscription.traffic_limit_gb = traffic_limit_gb + from sqlalchemy import delete as sql_delete - await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id)) - subscription.purchased_traffic_gb = 0 - subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика - logger.info(f'📊 Обновлен лимит трафика: {old_traffic} ГБ → {traffic_limit_gb} ГБ (все докупки сброшены)') + from app.database.models import TrafficPurchase + + await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id)) + subscription.purchased_traffic_gb = 0 + subscription.traffic_reset_at = None + logger.info(f'📊 Обновлен лимит трафика: {old_traffic} ГБ → {traffic_limit_gb} ГБ (смена тарифа, докупки сброшены)') + else: + # При ПРОДЛЕНИИ того же тарифа — сохраняем докупленный трафик + purchased = subscription.purchased_traffic_gb or 0 + subscription.traffic_limit_gb = traffic_limit_gb + purchased + logger.info(f'📊 Обновлен лимит трафика: {old_traffic} ГБ → {traffic_limit_gb + purchased} ГБ (докупки сохранены: {purchased} ГБ)') elif settings.RESET_TRAFFIC_ON_PAYMENT: subscription.traffic_used_gb = 0.0 # В режиме тарифов сохраняем докупленный трафик при продлении From bee4aa42842b8b6611c7c268bcfced408a227bc0 Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 11 Feb 2026 04:44:15 +0300 Subject: [PATCH 14/16] fix: protect server counter callers and fix tariff change detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wrap unprotected add/remove_user_to/from_servers calls in try/except in miniapp.py and cabinet subscription.py to prevent 500 errors - Fix is_tariff_change to include classic-to-tariff transitions (subscription.tariff_id=None → new tariff_id) so purchased traffic is properly reset when switching modes --- app/cabinet/routes/subscription.py | 5 ++++- app/database/crud/subscription.py | 3 ++- app/webapi/routes/miniapp.py | 10 ++++++++-- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/app/cabinet/routes/subscription.py b/app/cabinet/routes/subscription.py index fd3d39f9..407ffbea 100644 --- a/app/cabinet/routes/subscription.py +++ b/app/cabinet/routes/subscription.py @@ -3216,7 +3216,10 @@ async def update_countries( added_server_ids = await get_server_ids_by_uuids(db, added) if added_server_ids: await add_subscription_servers(db, user.subscription, added_server_ids, added_server_prices) - await add_user_to_servers(db, added_server_ids) + try: + await add_user_to_servers(db, added_server_ids) + except Exception as e: + logger.error(f'Ошибка обновления счётчика серверов: {e}') # Update connected squads user.subscription.connected_squads = selected_countries diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index b76b2cf5..e619d1e9 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -357,8 +357,9 @@ async def extend_subscription( ) # Определяем, происходит ли СМЕНА тарифа (а не продление того же) + # Включает переход из классического режима (tariff_id=None) в тарифный is_tariff_change = ( - tariff_id is not None and subscription.tariff_id is not None and tariff_id != subscription.tariff_id + tariff_id is not None and (subscription.tariff_id is None or tariff_id != subscription.tariff_id) ) if is_tariff_change: diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index d1e3f0e5..e4b10fbc 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -5926,7 +5926,10 @@ async def update_subscription_servers_endpoint( if added_server_ids: await add_subscription_servers(db, subscription, added_server_ids, added_server_prices) - await add_user_to_servers(db, added_server_ids) + try: + await add_user_to_servers(db, added_server_ids) + except Exception as e: + logger.error(f'Ошибка обновления счётчика серверов (add): {e}') removed_server_ids = [ catalog[uuid].get('server_id') for uuid in removed if catalog[uuid].get('server_id') is not None @@ -5934,7 +5937,10 @@ async def update_subscription_servers_endpoint( if removed_server_ids: await remove_subscription_servers(db, subscription.id, removed_server_ids) - await remove_user_from_servers(db, removed_server_ids) + try: + await remove_user_from_servers(db, removed_server_ids) + except Exception as e: + logger.error(f'Ошибка обновления счётчика серверов (remove): {e}') ordered_selection = [] seen_selection = set() From 887ea9cf5a9e6429cb16e8b95eab9e7e7156ffdb Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 11 Feb 2026 04:45:42 +0300 Subject: [PATCH 15/16] style: format subscription.py with ruff --- app/database/crud/subscription.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index e619d1e9..da0d8d2d 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -358,9 +358,7 @@ async def extend_subscription( # Определяем, происходит ли СМЕНА тарифа (а не продление того же) # Включает переход из классического режима (tariff_id=None) в тарифный - is_tariff_change = ( - tariff_id is not None and (subscription.tariff_id is None or tariff_id != subscription.tariff_id) - ) + is_tariff_change = tariff_id is not None and (subscription.tariff_id is None or tariff_id != subscription.tariff_id) if is_tariff_change: logger.info(f'🔄 Обнаружена СМЕНА тарифа: {subscription.tariff_id} → {tariff_id}') @@ -453,12 +451,16 @@ async def extend_subscription( await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id)) subscription.purchased_traffic_gb = 0 subscription.traffic_reset_at = None - logger.info(f'📊 Обновлен лимит трафика: {old_traffic} ГБ → {traffic_limit_gb} ГБ (смена тарифа, докупки сброшены)') + logger.info( + f'📊 Обновлен лимит трафика: {old_traffic} ГБ → {traffic_limit_gb} ГБ (смена тарифа, докупки сброшены)' + ) else: # При ПРОДЛЕНИИ того же тарифа — сохраняем докупленный трафик purchased = subscription.purchased_traffic_gb or 0 subscription.traffic_limit_gb = traffic_limit_gb + purchased - logger.info(f'📊 Обновлен лимит трафика: {old_traffic} ГБ → {traffic_limit_gb + purchased} ГБ (докупки сохранены: {purchased} ГБ)') + logger.info( + f'📊 Обновлен лимит трафика: {old_traffic} ГБ → {traffic_limit_gb + purchased} ГБ (докупки сохранены: {purchased} ГБ)' + ) elif settings.RESET_TRAFFIC_ON_PAYMENT: subscription.traffic_used_gb = 0.0 # В режиме тарифов сохраняем докупленный трафик при продлении From 3193ffbd1bee07cb79824d87cb0f77b473b22989 Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 11 Feb 2026 05:50:34 +0300 Subject: [PATCH 16/16] fix: change CryptoBot URL priority to bot_invoice_url for Telegram opening --- app/cabinet/routes/balance.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/cabinet/routes/balance.py b/app/cabinet/routes/balance.py index 47575599..4c109ceb 100644 --- a/app/cabinet/routes/balance.py +++ b/app/cabinet/routes/balance.py @@ -420,9 +420,9 @@ async def create_topup( ) if result: payment_url = ( - result.get('web_app_invoice_url') + result.get('bot_invoice_url') or result.get('mini_app_invoice_url') - or result.get('bot_invoice_url') + or result.get('web_app_invoice_url') ) payment_id = result.get('invoice_id') or str(result.get('local_payment_id', 'pending')) else: