diff --git a/database/users.py b/database/users.py index 5d50739f..3425dfeb 100644 --- a/database/users.py +++ b/database/users.py @@ -30,7 +30,8 @@ async def add_user( language_code: str = None, is_bot: bool = False, source_code: str = None, -): + commit: bool = True, +) -> bool: try: stmt = ( insert(User) @@ -43,11 +44,17 @@ async def add_user( is_bot=is_bot, source_code=source_code, ) - .on_conflict_do_nothing(index_elements=[User.tg_id]) + .on_conflict_do_nothing(index_elements=["tg_id"]) + .returning(User.tg_id) ) - await session.execute(stmt) - await session.commit() + res = await session.execute(stmt) + inserted_tg_id = res.scalar_one_or_none() + if inserted_tg_id is None: + return False + if commit: + await session.commit() logger.info(f"[DB] Новый пользователь добавлен: {tg_id} (source: {source_code})") + return True except SQLAlchemyError as e: logger.error(f"[DB] Ошибка при добавлении пользователя {tg_id}: {e}") await session.rollback() @@ -121,58 +128,64 @@ async def upsert_user( is_bot: bool = False, only_if_exists: bool = False, ) -> dict | None: + """Создаёт пользователя или обновляет поля профиля.""" try: + now = datetime.utcnow() + returning_cols = list(User.__table__.c) + if only_if_exists: - result = await session.execute(select(User).where(User.tg_id == tg_id)) - user = result.scalar_one_or_none() - if not user: - return None - await session.execute( + username_value = username if username else User.username + first_name_value = first_name if first_name else User.first_name + last_name_value = last_name if last_name else User.last_name + language_code_value = language_code if language_code else User.language_code + + res = await session.execute( update(User) .where(User.tg_id == tg_id) .values( - username=username or user.username, - first_name=first_name or user.first_name, - last_name=last_name or user.last_name, - language_code=language_code or user.language_code, + username=username_value, + first_name=first_name_value, + last_name=last_name_value, + language_code=language_code_value, is_bot=is_bot, - updated_at=datetime.utcnow(), + updated_at=now, ) + .returning(*returning_cols) ) + row = res.mappings().one_or_none() + if row is None: + return None await session.commit() - result = await session.execute(select(User).where(User.tg_id == tg_id)) - return dict(result.scalar_one().__dict__) - else: - res = await session.execute( - insert(User) - .values( - tg_id=tg_id, - username=username, - first_name=first_name, - last_name=last_name, - language_code=language_code, - is_bot=is_bot, - created_at=datetime.utcnow(), - updated_at=datetime.utcnow(), - ) - .on_conflict_do_update( - index_elements=[User.tg_id], - set_={ - "username": username, - "first_name": first_name, - "last_name": last_name, - "language_code": language_code, - "is_bot": is_bot, - "updated_at": datetime.utcnow(), - }, - ) - .returning(User) + return dict(row) + + res = await session.execute( + insert(User) + .values( + tg_id=tg_id, + username=username, + first_name=first_name, + last_name=last_name, + language_code=language_code, + is_bot=is_bot, + created_at=now, + updated_at=now, ) - obj = res.scalar_one() - await session.commit() - d = obj.__dict__.copy() - d.pop("_sa_instance_state", None) - return d + .on_conflict_do_update( + index_elements=[User.tg_id], + set_={ + "username": username, + "first_name": first_name, + "last_name": last_name, + "language_code": language_code, + "is_bot": is_bot, + "updated_at": now, + }, + ) + .returning(*returning_cols) + ) + row = res.mappings().one() + await session.commit() + return dict(row) except SQLAlchemyError as e: logger.error(f"[DB] Ошибка при UPSERT пользователя {tg_id}: {e}") await session.rollback() @@ -210,12 +223,10 @@ async def mark_trial_extended(tg_id: int, session: AsyncSession): async def get_user_snapshot(session: AsyncSession, tg_id: int) -> tuple[int, int] | None: + keys_count_sq = select(func.count(Key.client_id)).where(Key.tg_id == tg_id).scalar_subquery() + res = await session.execute( - select(func.coalesce(User.trial, 0), func.count(Key.client_id)) - .select_from(User) - .join(Key, Key.tg_id == User.tg_id, isouter=True) - .where(User.tg_id == tg_id) - .group_by(User.tg_id, User.trial) + select(func.coalesce(User.trial, 0), keys_count_sq).where(User.tg_id == tg_id) ) row = res.first() if row is None: @@ -223,17 +234,28 @@ async def get_user_snapshot(session: AsyncSession, tg_id: int) -> tuple[int, int return int(row[0]), int(row[1]) -async def upsert_source_if_empty(session: AsyncSession, tg_id: int, source_code: str) -> None: +async def upsert_source_if_empty( + session: AsyncSession, + tg_id: int, + source_code: str, + commit: bool = True, +) -> bool: if not source_code: - return + return False stmt = ( insert(User) .values(tg_id=tg_id, source_code=source_code) .on_conflict_do_update( - index_elements=[User.tg_id], + index_elements=["tg_id"], set_={"source_code": insert(User).excluded.source_code}, where=(User.source_code.is_(None)), ) + .returning(User.tg_id) ) - await session.execute(stmt) - await session.commit() + res = await session.execute(stmt) + changed_tg_id = res.scalar_one_or_none() + if changed_tg_id is None: + return False + if commit: + await session.commit() + return True diff --git a/handlers/coupons.py b/handlers/coupons.py index c2532641..9e7df522 100644 --- a/handlers/coupons.py +++ b/handlers/coupons.py @@ -18,7 +18,6 @@ from database import ( add_payment, add_user, check_coupon_usage, - check_user_exists, create_coupon_usage, get_coupon_by_code, get_keys, @@ -108,20 +107,18 @@ async def activate_coupon( await state.clear() return - user_exists = await check_user_exists(session, user_id) - if not user_exists: - if isinstance(user, dict): - await add_user(session=session, **user) - else: - await add_user( - session=session, - tg_id=user.id, - username=getattr(user, "username", None), - first_name=getattr(user, "first_name", None), - last_name=getattr(user, "last_name", None), - language_code=getattr(user, "language_code", None), - is_bot=getattr(user, "is_bot", False), - ) + if isinstance(user, dict): + await add_user(session=session, **user) + else: + await add_user( + session=session, + tg_id=user.id, + username=getattr(user, "username", None), + first_name=getattr(user, "first_name", None), + last_name=getattr(user, "last_name", None), + language_code=getattr(user, "language_code", None), + is_bot=getattr(user, "is_bot", False), + ) if coupon.amount > 0: try: diff --git a/handlers/keys/key_create.py b/handlers/keys/key_create.py index dfa0acda..edbdbad7 100644 --- a/handlers/keys/key_create.py +++ b/handlers/keys/key_create.py @@ -20,7 +20,6 @@ from config import ( from core.bootstrap import MODES_CONFIG, NOTIFICATIONS_CONFIG from database import ( add_user, - check_user_exists, get_tariffs_for_cluster, get_trial, ) @@ -393,18 +392,17 @@ async def create_key( selected_traffic_gb: int | None = None, selected_price_rub: int | None = None, ): - if not await check_user_exists(session, tg_id): - from_user = message_or_query.from_user if isinstance(message_or_query, CallbackQuery | Message) else None - if from_user: - await add_user( - tg_id=from_user.id, - username=from_user.username, - first_name=from_user.first_name, - last_name=from_user.last_name, - language_code=from_user.language_code, - is_bot=from_user.is_bot, - session=session, - ) + from_user = message_or_query.from_user if isinstance(message_or_query, CallbackQuery | Message) else None + if from_user: + await add_user( + tg_id=from_user.id, + username=from_user.username, + first_name=from_user.first_name, + last_name=from_user.last_name, + language_code=from_user.language_code, + is_bot=from_user.is_bot, + session=session, + ) use_country_selection = bool(MODES_CONFIG.get("COUNTRY_SELECTION_ENABLED", USE_COUNTRY_SELECTION)) diff --git a/handlers/refferal.py b/handlers/refferal.py index 56957762..6b625578 100644 --- a/handlers/refferal.py +++ b/handlers/refferal.py @@ -25,7 +25,6 @@ from core.bootstrap import BUTTONS_CONFIG, MODES_CONFIG from database import ( add_referral, add_user, - check_user_exists, get_referral_by_referred_id, get_referral_stats, ) @@ -271,23 +270,22 @@ async def handle_referral_link( await message.answer("❌ Вы уже использовали реферальную ссылку.") return - user_exists = await check_user_exists(session, user_id) - if user_exists: + if isinstance(user, dict): + inserted = await add_user(session=session, **user) + else: + inserted = await add_user( + session=session, + tg_id=user.id, + username=getattr(user, "username", None), + first_name=getattr(user, "first_name", None), + last_name=getattr(user, "last_name", None), + language_code=getattr(user, "language_code", None), + is_bot=getattr(user, "is_bot", False), + ) + + if not inserted: await message.answer("❌ Вы уже зарегистрированы и не можете стать рефералом.") return - if not user_exists: - if isinstance(user, dict): - await add_user(session=session, **user) - else: - await add_user( - session=session, - tg_id=user.id, - username=getattr(user, "username", None), - first_name=getattr(user, "first_name", None), - last_name=getattr(user, "last_name", None), - language_code=getattr(user, "language_code", None), - is_bot=getattr(user, "is_bot", False), - ) await add_referral(session, user_id, referrer_tg_id) diff --git a/handlers/tariffs/addons/key_addons_pack.py b/handlers/tariffs/addons/key_addons_pack.py index 81da9790..97d468cc 100644 --- a/handlers/tariffs/addons/key_addons_pack.py +++ b/handlers/tariffs/addons/key_addons_pack.py @@ -5,6 +5,7 @@ from aiogram import F, Router from aiogram.fsm.context import FSMContext from aiogram.types import CallbackQuery, InlineKeyboardButton from aiogram.utils.keyboard import InlineKeyboardBuilder +import sqlalchemy as sa from sqlalchemy.ext.asyncio import AsyncSession from config import USE_NEW_PAYMENT_FLOW @@ -17,6 +18,7 @@ from database import ( save_key_config_with_mode, update_balance, ) +from database.models import User from handlers.buttons import BACK, CONFIRM_ADDON_BUTTON_TEXT, PAYMENT from handlers.keys.key_view import render_key_info from handlers.payments.currency_rates import format_for_user @@ -613,252 +615,274 @@ async def handle_addons_confirm(callback: CallbackQuery, state: FSMContext, sess tg_id = callback.from_user.id data = await state.get_data() - email = data.get("addon_key_email") - tariff_id = data.get("addon_tariff_id") - selected_devices = data.get("addon_selected_device_limit") - selected_traffic_gb = data.get("addon_selected_traffic_gb") - current_devices = data.get("addon_current_device_limit") - current_traffic_gb = data.get("addon_current_traffic_gb") - - logger.info( - "[ADDONS] PACK_MODE handle_addons_confirm: " - f"tg_id={tg_id} email={email} tariff_id={tariff_id} " - f"selected_devices={selected_devices} selected_traffic_gb={selected_traffic_gb} " - f"current_devices={current_devices} current_traffic_gb={current_traffic_gb}" - ) - - if not email or not tariff_id: - await callback.message.answer("❌ Данные для изменения подписки не найдены.") - await state.clear() + if data.get("addon_confirm_in_progress"): + await callback.answer("⏳ Уже обрабатываю...", show_alert=False) return - record = await get_key_details(session, email) - if not record: - logger.warning(f"[ADDONS] PACK_MODE: подписка {email} не найдена в handle_addons_confirm") - await callback.message.answer("❌ Подписка не найдена.") - await state.clear() - return + await state.update_data(addon_confirm_in_progress=True) - tariff = await get_tariff_by_id(session, int(tariff_id)) - if not tariff: - logger.error(f"[ADDONS] PACK_MODE: тариф {tariff_id} не найден в handle_addons_confirm") - await callback.message.answer("❌ Тариф не найден.") - await state.clear() - return - - cfg = data.get("addon_tariff_config") or {} - device_options = cfg.get("device_options") or [] - traffic_options = cfg.get("traffic_options_gb") or [] - - pack_devices, pack_traffic, pack_mode = get_pack_flags() - - has_device_option = pack_devices and bool(device_options) - has_traffic_option = pack_traffic and bool(traffic_options) - - if has_device_option and current_devices is not None and int(current_devices) == 0: - has_device_option = False - selected_devices = None - - if has_traffic_option and current_traffic_gb is not None and int(current_traffic_gb) == 0: - has_traffic_option = False - selected_traffic_gb = None - - current_devices_for_price = int(current_devices) if current_devices is not None else None - current_traffic_for_price = int(current_traffic_gb) if current_traffic_gb is not None else None - - base_price_for_current = calculate_config_price( - tariff=tariff, - selected_device_limit=current_devices_for_price, - selected_traffic_gb=current_traffic_for_price, - ) try: - base_price_for_current_int = int(base_price_for_current) if base_price_for_current is not None else 0 - except (TypeError, ValueError): - base_price_for_current_int = 0 - - recalc_enabled = bool( - MODES_CONFIG.get( - "KEY_ADDONS_RECALC_PRICE", - TARIFFS_CONFIG.get("KEY_ADDONS_RECALC_PRICE", False), - ) - ) - - diff_full = calc_pack_full_price_rub( - tariff=tariff, - has_device_option=has_device_option, - has_traffic_option=has_traffic_option, - selected_devices=int(selected_devices) if selected_devices is not None else None, - selected_traffic_gb=int(selected_traffic_gb) if selected_traffic_gb is not None else None, - ) - - if recalc_enabled: - remaining_seconds, total_seconds = calc_remaining_ratio_seconds(record.get("expiry_time"), tariff) - extra_price = int((diff_full * remaining_seconds + total_seconds - 1) // total_seconds) - total_price_after_purchase = base_price_for_current_int + extra_price - else: - extra_price = int(diff_full) - total_price_after_purchase = base_price_for_current_int + diff_full - - logger.debug( - "[ADDONS] PACK_MODE confirm prices: " - f"base_price_for_current={base_price_for_current_int} diff_full={diff_full} " - f"extra_price={extra_price} recalc_enabled={recalc_enabled} total_price_after_purchase={total_price_after_purchase} " - f"has_device_option={has_device_option} has_traffic_option={has_traffic_option} " - f"pack_mode={pack_mode!r}" - ) - - if extra_price <= 0: - logger.info(f"[ADDONS] PACK_MODE: extra_price <= 0, доплата не требуется, email={email}") - await state.clear() - await render_key_info(callback.message, session, email, "img/pic_view.jpg") - await callback.answer(ADDONS_NO_EXTRA_PAYMENT_TEXT, show_alert=True) - return - - balance = await get_balance(session, tg_id) - logger.debug(f"[ADDONS] PACK_MODE balance check: tg_id={tg_id} balance={balance} extra_price={extra_price}") - - if balance < extra_price: - required_amount = ceil(extra_price - balance) - language_code = getattr(callback.from_user, "language_code", None) - required_amount_text = await format_for_user(session, tg_id, float(required_amount), language_code) + email = data.get("addon_key_email") + tariff_id = data.get("addon_tariff_id") + selected_devices = data.get("addon_selected_device_limit") + selected_traffic_gb = data.get("addon_selected_traffic_gb") + current_devices = data.get("addon_current_device_limit") + current_traffic_gb = data.get("addon_current_traffic_gb") logger.info( - "[ADDONS] PACK_MODE: недостаточно средств " - f"balance={balance} extra_price={extra_price} required_amount={required_amount} " - f"tg_id={tg_id} USE_NEW_PAYMENT_FLOW={USE_NEW_PAYMENT_FLOW}" + "[ADDONS] PACK_MODE handle_addons_confirm: " + f"tg_id={tg_id} email={email} tariff_id={tariff_id} " + f"selected_devices={selected_devices} selected_traffic_gb={selected_traffic_gb} " + f"current_devices={current_devices} current_traffic_gb={current_traffic_gb}" ) - if USE_NEW_PAYMENT_FLOW: - handled = await try_fast_payment_flow( - callback, - session, - state, - tg_id=tg_id, - temp_key="waiting_for_addons_payment", - temp_payload={ - "email": email, - "tariff_id": int(tariff_id), - "selected_device_limit": selected_devices, - "selected_traffic_gb": selected_traffic_gb, - "current_device_limit": current_devices, - "current_traffic_gb": current_traffic_gb, - "required_amount": required_amount, - }, - required_amount=required_amount, - ) - logger.debug(f"[ADDONS] PACK_MODE try_fast_payment_flow handled={handled} tg_id={tg_id} email={email}") - if handled: - return + if not email or not tariff_id: + await callback.message.answer("❌ Данные для изменения подписки не найдены.") + await state.clear() + return - builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay")) - await edit_or_send_message( - target_message=callback.message, - text=INSUFFICIENT_FUNDS_RENEWAL_MSG.format(required_amount=required_amount_text), - reply_markup=builder.as_markup(), + record = await get_key_details(session, email) + if not record: + logger.warning(f"[ADDONS] PACK_MODE: подписка {email} не найдена в handle_addons_confirm") + await callback.message.answer("❌ Подписка не найдена.") + await state.clear() + return + + tariff = await get_tariff_by_id(session, int(tariff_id)) + if not tariff: + logger.error(f"[ADDONS] PACK_MODE: тариф {tariff_id} не найден в handle_addons_confirm") + await callback.message.answer("❌ Тариф не найден.") + await state.clear() + return + + cfg = data.get("addon_tariff_config") or {} + device_options = cfg.get("device_options") or [] + traffic_options = cfg.get("traffic_options_gb") or [] + + pack_devices, pack_traffic, pack_mode = get_pack_flags() + + has_device_option = pack_devices and bool(device_options) + has_traffic_option = pack_traffic and bool(traffic_options) + + if has_device_option and current_devices is not None and int(current_devices) == 0: + has_device_option = False + selected_devices = None + + if has_traffic_option and current_traffic_gb is not None and int(current_traffic_gb) == 0: + has_traffic_option = False + selected_traffic_gb = None + + current_devices_for_price = int(current_devices) if current_devices is not None else None + current_traffic_for_price = int(current_traffic_gb) if current_traffic_gb is not None else None + + base_price_for_current = calculate_config_price( + tariff=tariff, + selected_device_limit=current_devices_for_price, + selected_traffic_gb=current_traffic_for_price, ) - return - - try: - expiry_time = record["expiry_time"] - client_id = record["client_id"] - server_id = record["server_id"] - - device_limit_effective_current, traffic_limit_bytes_effective_current = await get_effective_limits_for_key( - session=session, - tariff_id=int(tariff_id), - selected_device_limit=int(current_devices) if current_devices is not None else None, - selected_traffic_gb=int(current_traffic_gb) if current_traffic_gb is not None else None, - ) - traffic_limit_gb_effective_current = ( - int(traffic_limit_bytes_effective_current / GB) if traffic_limit_bytes_effective_current else 0 - ) - - new_device_limit_effective = device_limit_effective_current - new_traffic_limit_gb_effective = traffic_limit_gb_effective_current - - if has_device_option and selected_devices is not None: - pack_devices_val = int(selected_devices) - if pack_devices_val <= 0 or (new_device_limit_effective is not None and new_device_limit_effective <= 0): - new_device_limit_effective = 0 - else: - if new_device_limit_effective is None: - new_device_limit_effective = pack_devices_val - else: - new_device_limit_effective = new_device_limit_effective + pack_devices_val - - if has_traffic_option and selected_traffic_gb is not None: - pack_traffic_val = int(selected_traffic_gb) - if pack_traffic_val <= 0 or new_traffic_limit_gb_effective <= 0: - new_traffic_limit_gb_effective = 0 - else: - new_traffic_limit_gb_effective = new_traffic_limit_gb_effective + pack_traffic_val - - current_subgroup = None try: - current_tariff_id = record.get("tariff_id") - if current_tariff_id: - current_tariff = await get_tariff_by_id(session, int(current_tariff_id)) - if current_tariff: - current_subgroup = current_tariff.get("subgroup_title") - except Exception as error: - logger.warning(f"[ADDONS] PACK_MODE: не удалось определить текущую подгруппу: {error}") + base_price_for_current_int = int(base_price_for_current) if base_price_for_current is not None else 0 + except (TypeError, ValueError): + base_price_for_current_int = 0 - target_subgroup = tariff.get("subgroup_title") - old_subgroup = current_subgroup + recalc_enabled = bool( + MODES_CONFIG.get( + "KEY_ADDONS_RECALC_PRICE", + TARIFFS_CONFIG.get("KEY_ADDONS_RECALC_PRICE", False), + ) + ) - total_gb = new_traffic_limit_gb_effective - hwid_device_limit_to_set = new_device_limit_effective + diff_full = calc_pack_full_price_rub( + tariff=tariff, + has_device_option=has_device_option, + has_traffic_option=has_traffic_option, + selected_devices=int(selected_devices) if selected_devices is not None else None, + selected_traffic_gb=int(selected_traffic_gb) if selected_traffic_gb is not None else None, + ) + + if recalc_enabled: + remaining_seconds, total_seconds = calc_remaining_ratio_seconds(record.get("expiry_time"), tariff) + extra_price = int((diff_full * remaining_seconds + total_seconds - 1) // total_seconds) + total_price_after_purchase = base_price_for_current_int + extra_price + else: + extra_price = int(diff_full) + total_price_after_purchase = base_price_for_current_int + diff_full logger.debug( - "[ADDONS] PACK_MODE renew_key_in_cluster params: " - f"server_id={server_id} email={email} client_id={client_id} total_gb={total_gb} " - f"hwid_device_limit_to_set={hwid_device_limit_to_set} target_subgroup={target_subgroup} " - f"old_subgroup={old_subgroup}" + "[ADDONS] PACK_MODE confirm prices: " + f"base_price_for_current={base_price_for_current_int} diff_full={diff_full} " + f"extra_price={extra_price} recalc_enabled={recalc_enabled} total_price_after_purchase={total_price_after_purchase} " + f"has_device_option={has_device_option} has_traffic_option={has_traffic_option} " + f"pack_mode={pack_mode!r}" ) - await renew_key_in_cluster( - cluster_id=server_id, - email=email, - client_id=client_id, - new_expiry_time=expiry_time, - total_gb=total_gb, - session=session, - hwid_device_limit=hwid_device_limit_to_set, - reset_traffic=False, - target_subgroup=target_subgroup, - old_subgroup=old_subgroup, - plan=int(tariff_id), - ) + if extra_price <= 0: + logger.info(f"[ADDONS] PACK_MODE: extra_price <= 0, доплата не требуется, email={email}") + await state.clear() + await render_key_info(callback.message, session, email, "img/pic_view.jpg") + await callback.answer(ADDONS_NO_EXTRA_PAYMENT_TEXT, show_alert=True) + return - await update_balance(session, tg_id, -extra_price) + balance = await get_balance(session, tg_id) + logger.debug(f"[ADDONS] PACK_MODE balance check: tg_id={tg_id} balance={balance} extra_price={extra_price}") - await save_key_config_with_mode( - session=session, - email=email, - selected_devices=new_device_limit_effective, - selected_traffic_gb=new_traffic_limit_gb_effective, - total_price=int(total_price_after_purchase), - has_device_choice=has_device_option, - has_traffic_choice=has_traffic_option, - config_mode="pack", - ) + if balance < extra_price: + required_amount = ceil(extra_price - balance) + language_code = getattr(callback.from_user, "language_code", None) + required_amount_text = await format_for_user(session, tg_id, float(required_amount), language_code) - await session.commit() + logger.info( + "[ADDONS] PACK_MODE: недостаточно средств " + f"balance={balance} extra_price={extra_price} required_amount={required_amount} " + f"tg_id={tg_id} USE_NEW_PAYMENT_FLOW={USE_NEW_PAYMENT_FLOW}" + ) - logger.info( - "[ADDONS] PACK_MODE успешная покупка пакета: " - f"tg_id={tg_id} email={email} extra_price={extra_price} " - f"new_device_limit_effective={new_device_limit_effective} " - f"new_traffic_limit_gb_effective={new_traffic_limit_gb_effective} " - f"recalc_enabled={recalc_enabled} pack_mode={pack_mode!r}" - ) + if USE_NEW_PAYMENT_FLOW: + handled = await try_fast_payment_flow( + callback, + session, + state, + tg_id=tg_id, + temp_key="waiting_for_addons_payment", + temp_payload={ + "email": email, + "tariff_id": int(tariff_id), + "selected_device_limit": selected_devices, + "selected_traffic_gb": selected_traffic_gb, + "current_device_limit": current_devices, + "current_traffic_gb": current_traffic_gb, + "required_amount": required_amount, + }, + required_amount=required_amount, + ) + logger.debug(f"[ADDONS] PACK_MODE try_fast_payment_flow handled={handled} tg_id={tg_id} email={email}") + if handled: + return - await state.clear() - await render_key_info(callback.message, session, email, "img/pic_view.jpg") - await callback.answer(ADDONS_PACK_SUCCESS_TEXT, show_alert=True) + builder = InlineKeyboardBuilder() + builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay")) + await edit_or_send_message( + target_message=callback.message, + text=INSUFFICIENT_FUNDS_RENEWAL_MSG.format(required_amount=required_amount_text), + reply_markup=builder.as_markup(), + ) + return - except Exception as error: - logger.error(f"[ADDONS] PACK_MODE ошибка при покупке пакета для {email}: {error}") - await callback.message.answer("❌ Ошибка при обновлении подписки. Попробуйте позже.") - await state.clear() + try: + expiry_time = record["expiry_time"] + client_id = record["client_id"] + server_id = record["server_id"] + + device_limit_effective_current, traffic_limit_bytes_effective_current = await get_effective_limits_for_key( + session=session, + tariff_id=int(tariff_id), + selected_device_limit=int(current_devices) if current_devices is not None else None, + selected_traffic_gb=int(current_traffic_gb) if current_traffic_gb is not None else None, + ) + traffic_limit_gb_effective_current = ( + int(traffic_limit_bytes_effective_current / GB) if traffic_limit_bytes_effective_current else 0 + ) + + new_device_limit_effective = device_limit_effective_current + new_traffic_limit_gb_effective = traffic_limit_gb_effective_current + + if has_device_option and selected_devices is not None: + pack_devices_val = int(selected_devices) + if pack_devices_val <= 0 or (new_device_limit_effective is not None and new_device_limit_effective <= 0): + new_device_limit_effective = 0 + else: + if new_device_limit_effective is None: + new_device_limit_effective = pack_devices_val + else: + new_device_limit_effective = new_device_limit_effective + pack_devices_val + + if has_traffic_option and selected_traffic_gb is not None: + pack_traffic_val = int(selected_traffic_gb) + if pack_traffic_val <= 0 or new_traffic_limit_gb_effective <= 0: + new_traffic_limit_gb_effective = 0 + else: + new_traffic_limit_gb_effective = new_traffic_limit_gb_effective + pack_traffic_val + + current_subgroup = None + try: + current_tariff_id = record.get("tariff_id") + if current_tariff_id: + current_tariff = await get_tariff_by_id(session, int(current_tariff_id)) + if current_tariff: + current_subgroup = current_tariff.get("subgroup_title") + except Exception as error: + logger.warning(f"[ADDONS] PACK_MODE: не удалось определить текущую подгруппу: {error}") + + target_subgroup = tariff.get("subgroup_title") + old_subgroup = current_subgroup + + total_gb = new_traffic_limit_gb_effective + hwid_device_limit_to_set = new_device_limit_effective + + logger.debug( + "[ADDONS] PACK_MODE renew_key_in_cluster params: " + f"server_id={server_id} email={email} client_id={client_id} total_gb={total_gb} " + f"hwid_device_limit_to_set={hwid_device_limit_to_set} target_subgroup={target_subgroup} " + f"old_subgroup={old_subgroup}" + ) + + await renew_key_in_cluster( + cluster_id=server_id, + email=email, + client_id=client_id, + new_expiry_time=expiry_time, + total_gb=total_gb, + session=session, + hwid_device_limit=hwid_device_limit_to_set, + reset_traffic=False, + target_subgroup=target_subgroup, + old_subgroup=old_subgroup, + plan=int(tariff_id), + ) + + result = await session.execute( + sa.update(User) + .where(User.tg_id == tg_id, User.balance >= extra_price) + .values(balance=User.balance - extra_price) + ) + if (result.rowcount or 0) != 1: + await session.rollback() + await callback.answer("❌ Недостаточно средств.", show_alert=True) + await state.clear() + return + + await save_key_config_with_mode( + session=session, + email=email, + selected_devices=new_device_limit_effective, + selected_traffic_gb=new_traffic_limit_gb_effective, + total_price=int(total_price_after_purchase), + has_device_choice=has_device_option, + has_traffic_choice=has_traffic_option, + config_mode="pack", + ) + + await session.commit() + + logger.info( + "[ADDONS] PACK_MODE успешная покупка пакета: " + f"tg_id={tg_id} email={email} extra_price={extra_price} " + f"new_device_limit_effective={new_device_limit_effective} " + f"new_traffic_limit_gb_effective={new_traffic_limit_gb_effective} " + f"recalc_enabled={recalc_enabled} pack_mode={pack_mode!r}" + ) + + await state.clear() + await render_key_info(callback.message, session, email, "img/pic_view.jpg") + await callback.answer(ADDONS_PACK_SUCCESS_TEXT, show_alert=True) + + except Exception as error: + logger.error(f"[ADDONS] PACK_MODE ошибка при покупке пакета для {email}: {error}") + await callback.message.answer("❌ Ошибка при обновлении подписки. Попробуйте позже.") + await state.clear() + + finally: + try: + await state.update_data(addon_confirm_in_progress=False) + except Exception: + pass diff --git a/middlewares/throttling.py b/middlewares/throttling.py index 710744c5..00099031 100644 --- a/middlewares/throttling.py +++ b/middlewares/throttling.py @@ -5,27 +5,30 @@ from cachetools import TTLCache class ThrottlingMiddleware(BaseMiddleware): def __init__(self) -> None: - self.cache = TTLCache(maxsize=10_000, ttl=1.0) - self.throttle_notice_cache = TTLCache(maxsize=10_000, ttl=1.0) + self.cache = TTLCache(maxsize=50_000, ttl=1.0) + self.throttle_notice_cache = TTLCache(maxsize=50_000, ttl=1.0) async def __call__(self, handler, event, data): + if not isinstance(event, CallbackQuery): + return await handler(event, data) + user_id = event.from_user.id if event.from_user else None if user_id is None: return await handler(event, data) - current_count = self.cache.get(user_id, 0) + key = (user_id, event.data or "") + current_count = self.cache.get(key, 0) - if current_count >= 3: - if isinstance(event, CallbackQuery) and user_id not in self.throttle_notice_cache: - self.throttle_notice_cache[user_id] = None + if current_count >= 2: + if key not in self.throttle_notice_cache: + self.throttle_notice_cache[key] = None bot: Bot = data["bot"] await bot.answer_callback_query( callback_query_id=event.id, - text="Слишком много запросов! Пожалуйста, подождите...", + text="Слишком много нажатий, подождите...", show_alert=False, ) - return None - else: - self.cache[user_id] = current_count + 1 + return + self.cache[key] = current_count + 1 return await handler(event, data) diff --git a/middlewares/user.py b/middlewares/user.py index 4b9dcdf7..c13fd4b6 100644 --- a/middlewares/user.py +++ b/middlewares/user.py @@ -1,19 +1,22 @@ from collections.abc import Awaitable, Callable +from datetime import datetime from time import monotonic from typing import Any from aiogram import BaseMiddleware from aiogram.types import TelegramObject, User +from sqlalchemy import update from sqlalchemy.ext.asyncio import AsyncSession from database import upsert_user +from database.models import User as DbUser from logger import logger class UserMiddleware(BaseMiddleware): def __init__(self, debounce_sec: float = 60.0) -> None: self._debounce = float(debounce_sec) - self._cache: dict[int, tuple[str, float, dict | None]] = {} + self._cache: dict[int, tuple[str, float, float, dict | None]] = {} async def __call__( self, @@ -40,11 +43,17 @@ class UserMiddleware(BaseMiddleware): cached = self._cache.get(uid) if cached: - cached_fingerprint, ts, cached_db_user = cached - if fingerprint == cached_fingerprint and now - ts < self._debounce: - return cached_db_user + cached_fingerprint, profile_ts, touch_ts, cached_db_user = cached + + if fingerprint == cached_fingerprint: + if now - touch_ts >= self._debounce: + db_user = await self._touch_user(uid, session) + self._cache[uid] = (cached_fingerprint, profile_ts, now, db_user or cached_db_user) + return db_user or cached_db_user + + if now - profile_ts < self._debounce: + return cached_db_user - logger.debug(f"Обработка пользователя: {uid}") db_user = await upsert_user( tg_id=uid, username=user.username, @@ -55,17 +64,33 @@ class UserMiddleware(BaseMiddleware): session=session, only_if_exists=True, ) - self._cache[uid] = (fingerprint, now, db_user) - if db_user: - logger.debug(f"Получены данные пользователя из БД: {uid}") + self._cache[uid] = (fingerprint, now, now, db_user) return db_user + async def _touch_user(self, tg_id: int, session: AsyncSession) -> dict | None: + now = datetime.utcnow() + res = await session.execute( + update(DbUser) + .where(DbUser.tg_id == tg_id) + .values(updated_at=now) + .returning(DbUser) + ) + obj = res.scalar_one_or_none() + if obj is None: + return None + await session.commit() + d = obj.__dict__.copy() + d.pop("_sa_instance_state", None) + return d + def _fingerprint(self, user: User) -> str: - return "|".join([ - str(user.id), - user.username or "", - user.first_name or "", - user.last_name or "", - user.language_code or "", - "1" if user.is_bot else "0", - ]) + return "|".join( + [ + str(user.id), + user.username or "", + user.first_name or "", + user.last_name or "", + user.language_code or "", + "1" if user.is_bot else "0", + ] + ) diff --git a/utils/versioning.py b/utils/versioning.py index 7c7efab1..7c0be933 100644 --- a/utils/versioning.py +++ b/utils/versioning.py @@ -92,4 +92,4 @@ def get_git_commit_number() -> str: def get_version() -> str: - return f"v.5.1-b261235 {get_git_commit_number()}" + return f"v.5.1-b23012603 {get_git_commit_number()}"