From 6ea52bf4067e467b54bcba67faeea5551a13a865 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Dec 2025 22:46:04 +0300 Subject: [PATCH 1/7] Update messages.py --- app/handlers/admin/messages.py | 88 ++++++++++++++++++++++++++-------- 1 file changed, 69 insertions(+), 19 deletions(-) diff --git a/app/handlers/admin/messages.py b/app/handlers/admin/messages.py index 5579417a..d4b34980 100644 --- a/app/handlers/admin/messages.py +++ b/app/handlers/admin/messages.py @@ -711,29 +711,56 @@ async def handle_media_selection( await state.update_data(has_media=False) await show_button_selector_callback(callback, db_user, state) return - + media_type = callback.data.replace('add_media_', '') - + media_instructions = { "photo": "📷 Отправьте фотографию для рассылки:", "video": "🎥 Отправьте видео для рассылки:", "document": "📄 Отправьте документ для рассылки:" } - + await state.update_data( media_type=media_type, waiting_for_media=True ) - - await callback.message.edit_text( + + instruction_text = ( f"{media_instructions.get(media_type, 'Отправьте медиафайл:')}\n\n" - f"Размер файла не должен превышать 50 МБ", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")] - ]), - parse_mode="HTML" + f"Размер файла не должен превышать 50 МБ" ) - + instruction_keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")] + ]) + + # Проверяем, является ли текущее сообщение медиа-сообщением + is_media_message = ( + callback.message.photo + or callback.message.video + or callback.message.document + or callback.message.animation + or callback.message.audio + or callback.message.voice + ) + + if is_media_message: + # Удаляем медиа-сообщение и отправляем новое текстовое + try: + await callback.message.delete() + except Exception: + pass + await callback.message.answer( + instruction_text, + reply_markup=instruction_keyboard, + parse_mode="HTML" + ) + else: + await callback.message.edit_text( + instruction_text, + reply_markup=instruction_keyboard, + parse_mode="HTML" + ) + await state.set_state(AdminStates.waiting_for_broadcast_media) await callback.answer() @@ -860,12 +887,12 @@ async def show_button_selector_callback( if selected_buttons is None: selected_buttons = list(DEFAULT_SELECTED_BUTTONS) await state.update_data(selected_buttons=selected_buttons) - + media_info = "" if has_media: media_type = data.get('media_type', 'файл') media_info = f"\n🖼️ Медиафайл: {media_type} добавлен" - + text = f""" 📘 Выбор дополнительных кнопок @@ -882,16 +909,39 @@ async def show_button_selector_callback( Выберите нужные кнопки и нажмите "Продолжить": """ - + keyboard = get_updated_message_buttons_selector_keyboard_with_media( selected_buttons, has_media, db_user.language ) - - await callback.message.edit_text( - text, - reply_markup=keyboard, - parse_mode="HTML" + + # Проверяем, является ли текущее сообщение медиа-сообщением + # (фото, видео, документ и т.д.) - для них нельзя использовать edit_text + is_media_message = ( + callback.message.photo + or callback.message.video + or callback.message.document + or callback.message.animation + or callback.message.audio + or callback.message.voice ) + + if is_media_message: + # Удаляем медиа-сообщение и отправляем новое текстовое + try: + await callback.message.delete() + except Exception: + pass # Игнорируем ошибки удаления + await callback.message.answer( + text, + reply_markup=keyboard, + parse_mode="HTML" + ) + else: + await callback.message.edit_text( + text, + reply_markup=keyboard, + parse_mode="HTML" + ) await callback.answer() From 664cbff1ce54f832c16220df4d27983c1707a9fb Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Dec 2025 23:05:52 +0300 Subject: [PATCH 2/7] Update tickets.py --- app/handlers/tickets.py | 48 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/app/handlers/tickets.py b/app/handlers/tickets.py index f4f5e301..48395e17 100644 --- a/app/handlers/tickets.py +++ b/app/handlers/tickets.py @@ -464,17 +464,61 @@ async def show_my_tickets_closed( await callback.answer() +def _split_long_block(block: str, max_len: int) -> list[str]: + """Разбивает слишком длинный блок на части.""" + if len(block) <= max_len: + return [block] + + parts = [] + remaining = block + while remaining: + if len(remaining) <= max_len: + parts.append(remaining) + break + # Ищем место для разрыва (перенос строки или пробел) + cut_at = max_len + newline_pos = remaining.rfind('\n', 0, max_len) + space_pos = remaining.rfind(' ', 0, max_len) + + if newline_pos > max_len // 2: + cut_at = newline_pos + 1 + elif space_pos > max_len // 2: + cut_at = space_pos + 1 + + parts.append(remaining[:cut_at]) + remaining = remaining[cut_at:] + + return parts + + def _split_text_into_pages(header: str, message_blocks: list[str], max_len: int = 3500) -> list[str]: + """Разбивает текст на страницы с учётом лимита Telegram.""" pages: list[str] = [] current = header + header_len = len(header) + block_max_len = max_len - header_len - 50 # запас для безопасности + for block in message_blocks: - if len(current) + len(block) > max_len: - pages.append(current) + # Если блок сам по себе слишком длинный — разбиваем его + if len(block) > block_max_len: + block_parts = _split_long_block(block, block_max_len) + for part in block_parts: + if len(current) + len(part) > max_len: + if current.strip() and current != header: + pages.append(current) + current = header + part + else: + current += part + elif len(current) + len(block) > max_len: + if current.strip() and current != header: + pages.append(current) current = header + block else: current += block + if current.strip(): pages.append(current) + return pages if pages else [header] From d57e0743a10338cfe494e8200ca18c5186079ea5 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Dec 2025 23:06:22 +0300 Subject: [PATCH 3/7] Update tickets.py --- app/handlers/admin/tickets.py | 220 ++++++++++++++++++++++++++-------- 1 file changed, 168 insertions(+), 52 deletions(-) diff --git a/app/handlers/admin/tickets.py b/app/handlers/admin/tickets.py index 2e7e38cf..123a9501 100644 --- a/app/handlers/admin/tickets.py +++ b/app/handlers/admin/tickets.py @@ -26,8 +26,67 @@ from app.utils.cache import RateLimitCache logger = logging.getLogger(__name__) +# Максимальная длина сообщения Telegram (с запасом) +MAX_MESSAGE_LEN = 3500 + + +def _split_long_block(block: str, max_len: int) -> list[str]: + """Разбивает слишком длинный блок на части.""" + if len(block) <= max_len: + return [block] + + parts = [] + remaining = block + while remaining: + if len(remaining) <= max_len: + parts.append(remaining) + break + cut_at = max_len + newline_pos = remaining.rfind('\n', 0, max_len) + space_pos = remaining.rfind(' ', 0, max_len) + + if newline_pos > max_len // 2: + cut_at = newline_pos + 1 + elif space_pos > max_len // 2: + cut_at = space_pos + 1 + + parts.append(remaining[:cut_at]) + remaining = remaining[cut_at:] + + return parts + + +def _split_text_into_pages(header: str, message_blocks: list[str], max_len: int = MAX_MESSAGE_LEN) -> list[str]: + """Разбивает текст на страницы с учётом лимита Telegram.""" + pages: list[str] = [] + current = header + header_len = len(header) + block_max_len = max_len - header_len - 50 + + for block in message_blocks: + if len(block) > block_max_len: + block_parts = _split_long_block(block, block_max_len) + for part in block_parts: + if len(current) + len(part) > max_len: + if current.strip() and current != header: + pages.append(current) + current = header + part + else: + current += part + elif len(current) + len(block) > max_len: + if current.strip() and current != header: + pages.append(current) + current = header + block + else: + current += block + + if current.strip(): + pages.append(current) + + return pages if pages else [header] + + - async def show_admin_tickets( @@ -133,15 +192,27 @@ async def view_admin_ticket( state: Optional[FSMContext] = None, ticket_id: Optional[int] = None ): - """Показать детали тикета для админа""" + """Показать детали тикета для админа с пагинацией""" if not (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)): texts = get_texts(db_user.language) await callback.answer(texts.ACCESS_DENIED, show_alert=True) return - - if ticket_id is None: + + # Парсим ticket_id и page из callback_data + page = 1 + data_str = callback.data or "" + + if data_str.startswith("admin_ticket_page_"): + # format: admin_ticket_page_{ticket_id}_{page} try: - ticket_id = int((callback.data or "").split("_")[-1]) + parts = data_str.split("_") + ticket_id = int(parts[3]) + page = max(1, int(parts[4])) + except (ValueError, IndexError): + pass + elif ticket_id is None: + try: + ticket_id = int(data_str.split("_")[-1]) except (ValueError, AttributeError): texts = get_texts(db_user.language) await callback.answer( @@ -152,9 +223,9 @@ async def view_admin_ticket( if state is None: state = FSMContext(callback.bot, callback.from_user.id) - + ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=True) - + if not ticket: texts = get_texts(db_user.language) await callback.answer( @@ -162,59 +233,60 @@ async def view_admin_ticket( show_alert=True ) return - + texts = get_texts(db_user.language) - - # Формируем текст тикета + + # Формируем заголовок тикета status_text = { TicketStatus.OPEN.value: texts.t("TICKET_STATUS_OPEN", "Открыт"), TicketStatus.ANSWERED.value: texts.t("TICKET_STATUS_ANSWERED", "Отвечен"), TicketStatus.CLOSED.value: texts.t("TICKET_STATUS_CLOSED", "Закрыт"), TicketStatus.PENDING.value: texts.t("TICKET_STATUS_PENDING", "В ожидании") }.get(ticket.status, ticket.status) - + user_name = ticket.user.full_name if ticket.user else "Unknown" telegram_id_display = ticket.user.telegram_id if ticket.user else "—" username_value = ticket.user.username if ticket.user else None - ticket_text = f"🎫 Тикет #{ticket.id}\n\n" - ticket_text += f"👤 Пользователь: {user_name}\n" - ticket_text += f"🆔 Telegram ID: {telegram_id_display}\n" + header = f"🎫 Тикет #{ticket.id}\n\n" + header += f"👤 Пользователь: {user_name}\n" + header += f"🆔 Telegram ID: {telegram_id_display}\n" if username_value: safe_username = html.escape(username_value) - ticket_text += f"📱 Username: @{safe_username}\n" - ticket_text += ( - f"🔗 ЛС: " - f"tg://resolve?domain={safe_username}\n" - ) + header += f"📱 Username: @{safe_username}\n" else: - ticket_text += "📱 Username: отсутствует\n" - if ticket.user and ticket.user.telegram_id: - chat_link = f"tg://user?id={int(ticket.user.telegram_id)}" - ticket_text += f"🔗 Чат по ID: {chat_link}\n" - ticket_text += "\n" - ticket_text += f"📝 Заголовок: {ticket.title}\n" - ticket_text += f"📊 Статус: {ticket.status_emoji} {status_text}\n" - ticket_text += f"📅 Создан: {ticket.created_at.strftime('%d.%m.%Y %H:%M')}\n" - ticket_text += f"🔄 Обновлен: {ticket.updated_at.strftime('%d.%m.%Y %H:%M')}\n\n" - + header += "📱 Username: отсутствует\n" + header += f"📝 Заголовок: {ticket.title}\n" + header += f"📊 Статус: {ticket.status_emoji} {status_text}\n" + header += f"📅 Создан: {ticket.created_at.strftime('%d.%m.%Y %H:%M')}\n\n" + if ticket.is_user_reply_blocked: if ticket.user_reply_block_permanent: - ticket_text += "🚫 Пользователь заблокирован навсегда для ответов в этом тикете\n" + header += "🚫 Пользователь заблокирован навсегда\n\n" elif ticket.user_reply_block_until: - ticket_text += f"⏳ Блок до: {ticket.user_reply_block_until.strftime('%d.%m.%Y %H:%M')}\n" - + header += f"⏳ Блок до: {ticket.user_reply_block_until.strftime('%d.%m.%Y %H:%M')}\n\n" + + # Формируем блоки сообщений + message_blocks: list[str] = [] if ticket.messages: - ticket_text += f"💬 Сообщения ({len(ticket.messages)}):\n\n" - + message_blocks.append(f"💬 Сообщения ({len(ticket.messages)}):\n\n") for msg in ticket.messages: sender = "👤 Пользователь" if msg.is_user_message else "🛠️ Поддержка" - ticket_text += f"{sender} ({msg.created_at.strftime('%d.%m %H:%M')}):\n" - ticket_text += f"{msg.message_text}\n\n" + block = ( + f"{sender} ({msg.created_at.strftime('%d.%m %H:%M')}):\n" + f"{msg.message_text}\n\n" + ) if getattr(msg, "has_media", False) and getattr(msg, "media_type", None) == "photo": - ticket_text += "📎 Вложение: фото\n\n" - - # Добавим кнопку "Вложения", если есть фото + block += "📎 Вложение: фото\n\n" + message_blocks.append(block) + + # Разбиваем на страницы + pages = _split_text_into_pages(header, message_blocks, max_len=MAX_MESSAGE_LEN) + total_pages = len(pages) + if page > total_pages: + page = total_pages + + # Формируем клавиатуру has_photos = any(getattr(m, "has_media", False) and getattr(m, "media_type", None) == "photo" for m in ticket.messages or []) keyboard = get_admin_ticket_view_keyboard( ticket_id, @@ -222,7 +294,8 @@ async def view_admin_ticket( db_user.language, is_user_blocked=ticket.is_user_reply_blocked ) - # Кнопка открытия профиля пользователя в админке + + # Кнопка профиля пользователя try: if ticket.user: admin_profile_btn = types.InlineKeyboardButton( @@ -232,34 +305,76 @@ async def view_admin_ticket( keyboard.inline_keyboard.insert(0, [admin_profile_btn]) except Exception: pass + # Кнопки ЛС и профиль try: if ticket.user and ticket.user.telegram_id and ticket.user.username: safe_username = html.escape(ticket.user.username) buttons_row = [] pm_url = f"tg://resolve?domain={safe_username}" - buttons_row.append(types.InlineKeyboardButton(text="✉ Написать в ЛС", url=pm_url)) + buttons_row.append(types.InlineKeyboardButton(text="✉ ЛС", url=pm_url)) profile_url = f"tg://user?id={ticket.user.telegram_id}" buttons_row.append(types.InlineKeyboardButton(text="👤 Профиль", url=profile_url)) if buttons_row: keyboard.inline_keyboard.insert(0, buttons_row) except Exception: pass + + # Кнопка вложений if has_photos: try: - keyboard.inline_keyboard.insert(0, [types.InlineKeyboardButton(text=texts.t("TICKET_ATTACHMENTS", "📎 Вложения"), callback_data=f"admin_ticket_attachments_{ticket_id}")]) + keyboard.inline_keyboard.insert(0, [ + types.InlineKeyboardButton( + text=texts.t("TICKET_ATTACHMENTS", "📎 Вложения"), + callback_data=f"admin_ticket_attachments_{ticket_id}" + ) + ]) except Exception: pass - # Рендер через фото-утилиту (с логотипом), внутри есть фоллбеки на текст - from app.utils.photo_message import edit_or_answer_photo - await edit_or_answer_photo( - callback=callback, - caption=ticket_text, - keyboard=keyboard, - parse_mode="HTML", - ) - # сохраняем id для дальнейших действий (ответ/статусы) + # Пагинация + if total_pages > 1: + nav_row = [] + if page > 1: + nav_row.append(types.InlineKeyboardButton( + text="⬅️", + callback_data=f"admin_ticket_page_{ticket_id}_{page - 1}" + )) + nav_row.append(types.InlineKeyboardButton( + text=f"{page}/{total_pages}", + callback_data="noop" + )) + if page < total_pages: + nav_row.append(types.InlineKeyboardButton( + text="➡️", + callback_data=f"admin_ticket_page_{ticket_id}_{page + 1}" + )) + try: + keyboard.inline_keyboard.insert(0, nav_row) + except Exception: + pass + + page_text = pages[page - 1] + + # Отправка сообщения + try: + await callback.message.edit_text( + page_text, + reply_markup=keyboard, + parse_mode="HTML" + ) + except TelegramBadRequest: + try: + await callback.message.delete() + except Exception: + pass + await callback.message.answer( + page_text, + reply_markup=keyboard, + parse_mode="HTML" + ) + + # Сохраняем id для дальнейших действий if state is not None: try: await state.update_data(ticket_id=ticket_id) @@ -1034,7 +1149,8 @@ def register_handlers(dp: Dispatcher): dp.callback_query.register(close_all_open_admin_tickets, F.data == "admin_tickets_close_all_open") dp.callback_query.register(view_admin_ticket, F.data.startswith("admin_view_ticket_")) - + dp.callback_query.register(view_admin_ticket, F.data.startswith("admin_ticket_page_")) + # Ответы на тикеты dp.callback_query.register( reply_to_admin_ticket, From ef8d9fe1ff395cad4847b0708135472abd431867 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Dec 2025 23:15:37 +0300 Subject: [PATCH 4/7] Update subscription_purchase_service.py --- app/services/subscription_purchase_service.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/services/subscription_purchase_service.py b/app/services/subscription_purchase_service.py index 272a0d83..163255da 100644 --- a/app/services/subscription_purchase_service.py +++ b/app/services/subscription_purchase_service.py @@ -373,13 +373,16 @@ class MiniAppSubscriptionPurchaseService: period_map: Dict[str, PurchasePeriodConfig] = {} default_devices = settings.DEFAULT_DEVICE_LIMIT - if subscription and getattr(subscription, "device_limit", None): + # Для триала НЕ используем его ограничения как дефолтные, + # чтобы при продлении клиент получил стандартные значения платной подписки + is_trial_subscription = subscription and getattr(subscription, "is_trial", False) + if subscription and getattr(subscription, "device_limit", None) and not is_trial_subscription: default_devices = max(default_devices, int(subscription.device_limit)) fixed_traffic_value = None if settings.is_traffic_fixed(): fixed_traffic_value = settings.get_fixed_traffic_limit() - elif subscription and subscription.traffic_limit_gb is not None: + elif subscription and subscription.traffic_limit_gb is not None and not is_trial_subscription: fixed_traffic_value = subscription.traffic_limit_gb default_period_days = available_periods[0] if available_periods else 30 From b0bf5131d4c52690ce510876ac8d0d8bb80026ca Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Dec 2025 09:09:12 +0300 Subject: [PATCH 5/7] Update subscription_service.py --- app/services/subscription_service.py | 100 ++++++++++++++++++++++++++- 1 file changed, 99 insertions(+), 1 deletion(-) diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index 1d8c0f96..5c7be1cc 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -465,7 +465,105 @@ class SubscriptionService: except Exception as e: logger.error(f"Ошибка синхронизации трафика: {e}") return False - + + async def ensure_subscription_synced( + self, + db: AsyncSession, + subscription: Subscription, + ) -> Tuple[bool, Optional[str]]: + """ + Проверяет и синхронизирует подписку с RemnaWave при необходимости. + + Если subscription_url отсутствует или данные не синхронизированы, + пытается обновить/создать пользователя в RemnaWave. + + Returns: + Tuple[bool, Optional[str]]: (успех, сообщение об ошибке) + """ + try: + user = await get_user_by_id(db, subscription.user_id) + if not user: + logger.error(f"Пользователь не найден для подписки {subscription.id}") + return False, "user_not_found" + + # Проверяем, нужна ли синхронизация + needs_sync = ( + not subscription.subscription_url + or not user.remnawave_uuid + ) + + if not needs_sync: + # Проверяем, существует ли пользователь в RemnaWave + try: + async with self.get_api_client() as api: + remnawave_user = await api.get_user_by_uuid(user.remnawave_uuid) + if not remnawave_user: + needs_sync = True + logger.warning( + f"Пользователь {user.remnawave_uuid} не найден в RemnaWave, требуется синхронизация" + ) + except Exception as check_error: + logger.warning(f"Не удалось проверить пользователя в RemnaWave: {check_error}") + # Продолжаем, возможно проблема временная + + if not needs_sync: + return True, None + + logger.info( + f"Синхронизация подписки {subscription.id} с RemnaWave " + f"(subscription_url={bool(subscription.subscription_url)}, " + f"remnawave_uuid={bool(user.remnawave_uuid)})" + ) + + # Пытаемся синхронизировать + result = None + if user.remnawave_uuid: + # Пробуем обновить существующего пользователя + result = await self.update_remnawave_user( + db, + subscription, + reset_traffic=False, + ) + # Если update не удался (пользователь удалён из RemnaWave) — пробуем создать + if not result: + logger.warning( + f"Не удалось обновить пользователя {user.remnawave_uuid} в RemnaWave, " + f"пробуем создать заново" + ) + # Сбрасываем старый UUID, create_remnawave_user установит новый + user.remnawave_uuid = None + result = await self.create_remnawave_user( + db, + subscription, + reset_traffic=False, + ) + else: + # Создаём нового пользователя + result = await self.create_remnawave_user( + db, + subscription, + reset_traffic=False, + ) + + if result: + await db.refresh(subscription) + await db.refresh(user) + logger.info( + f"Подписка {subscription.id} успешно синхронизирована с RemnaWave. " + f"URL: {subscription.subscription_url}" + ) + return True, None + else: + logger.error(f"Не удалось синхронизировать подписку {subscription.id} с RemnaWave") + return False, "sync_failed" + + except RemnaWaveAPIError as api_error: + logger.error(f"Ошибка RemnaWave API при синхронизации подписки {subscription.id}: {api_error}") + return False, "api_error" + except Exception as e: + logger.error(f"Ошибка синхронизации подписки {subscription.id}: {e}") + return False, "unknown_error" + async def calculate_subscription_price( self, period_days: int, From 929a8d5fa9540ba31063e6a0a7058cdd2cfc4f4d Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Dec 2025 09:09:46 +0300 Subject: [PATCH 6/7] Update purchase.py --- app/handlers/subscription/purchase.py | 32 ++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index 50825ae3..01a51d14 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -68,6 +68,8 @@ from app.services.trial_activation_service import ( rollback_trial_subscription_activation, ) +logger = logging.getLogger(__name__) + def _serialize_markup(markup: Optional[InlineKeyboardMarkup]) -> Optional[Any]: if markup is None: @@ -211,6 +213,11 @@ async def show_subscription_info( subscription_service = SubscriptionService() await subscription_service.sync_subscription_usage(db, subscription) + # Проверяем и синхронизируем подписку с RemnaWave если необходимо + sync_success, sync_error = await subscription_service.ensure_subscription_synced(db, subscription) + if not sync_success: + logger.warning(f"Не удалось синхронизировать подписку {subscription.id} с RemnaWave: {sync_error}") + await db.refresh(subscription) current_time = datetime.utcnow() @@ -2051,17 +2058,32 @@ async def confirm_purchase( promo_offer_discount_percent = 0 # Валидация: проверяем что cached_total_price соответствует ожидаемой финальной цене - # Допускаем небольшое расхождение из-за округления - is_valid = True - if abs(final_price - cached_total_price) > 100: # допуск 1₽ - # Если есть расхождение, логируем предупреждение но продолжаем с пересчитанной ценой + # Допускаем небольшое расхождение из-за округления (до 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 + elif price_difference > 100: # допуск 1₽ + # Небольшое расхождение - логируем предупреждение но продолжаем logger.warning( f"Расхождение цены для пользователя {db_user.telegram_id}: " f"кэш={cached_total_price/100}₽, пересчет={final_price/100}₽. " f"Используем пересчитанную цену." ) - # Используем пересчитанную цену вместо закэшированной для надежности + # Используем пересчитанную цену validation_total_price = calculated_total_before_promo logger.info(f"Расчет покупки подписки на {data['period_days']} дней ({months_in_period} мес):") From 6c740ad984b983bf292ccf4d394b42b4cfb34fd3 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Dec 2025 09:35:49 +0300 Subject: [PATCH 7/7] Update purchase.py --- app/handlers/subscription/purchase.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index 01a51d14..aaf37a3e 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -219,6 +219,7 @@ async def show_subscription_info( logger.warning(f"Не удалось синхронизировать подписку {subscription.id} с RemnaWave: {sync_error}") await db.refresh(subscription) + await db.refresh(db_user) current_time = datetime.utcnow()