From 59f0e42be7e3c679d15cf2fc6820ab7097cd2201 Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 27 Feb 2026 00:53:46 +0300 Subject: [PATCH] fix: prevent squad drop on admin subscription type change, require subscription for wheel spins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix active_internal_squads sent unconditionally as [] clearing Remnawave squads - Fix dead code in _change_subscription_type (was_trial saved before mutation) - Block wheel spins for users without active subscription (API + bot handler) - Add has_subscription field to wheel config response - Refund Stars to balance if spin payment arrives without subscription - Fix SQL injection in promocode lookup (f-string → parameterized query) - Remove redundant get_or_create_wheel_config call in stars handler --- app/cabinet/routes/wheel.py | 17 +++++++++++++ app/cabinet/schemas/wheel.py | 1 + app/handlers/admin/users.py | 3 ++- app/handlers/stars_payments.py | 38 ++++++++++++++++++++++++++-- app/services/subscription_service.py | 12 ++++++--- app/services/wheel_service.py | 6 ++++- 6 files changed, 70 insertions(+), 7 deletions(-) diff --git a/app/cabinet/routes/wheel.py b/app/cabinet/routes/wheel.py index b509478f..a8f965b1 100644 --- a/app/cabinet/routes/wheel.py +++ b/app/cabinet/routes/wheel.py @@ -50,6 +50,12 @@ async def get_wheel_config( # Проверяем доступность availability = await wheel_service.check_availability(db, user) + # Проверяем наличие подписки + from app.database.crud.subscription import get_subscription_by_user_id + + subscription = await get_subscription_by_user_id(db, user.id) + has_subscription = subscription is not None and subscription.is_active + prizes_display = [ WheelPrizeDisplay( id=p.id, @@ -77,6 +83,7 @@ async def get_wheel_config( can_pay_days=availability.can_pay_days, user_balance_kopeks=availability.user_balance_kopeks, required_balance_kopeks=availability.required_balance_kopeks, + has_subscription=has_subscription, ) @@ -213,6 +220,16 @@ async def create_stars_invoice( detail='Оплата Stars не включена', ) + # Проверяем наличие активной подписки + from app.database.crud.subscription import get_subscription_by_user_id + + subscription = await get_subscription_by_user_id(db, user.id) + if not subscription or not subscription.is_active: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Для использования колеса необходима активная подписка', + ) + # Проверяем лимит спинов spins_today = await get_user_spins_today(db, user.id) if config.daily_spin_limit > 0 and spins_today >= config.daily_spin_limit: diff --git a/app/cabinet/schemas/wheel.py b/app/cabinet/schemas/wheel.py index 616863f7..80e7ac1b 100644 --- a/app/cabinet/schemas/wheel.py +++ b/app/cabinet/schemas/wheel.py @@ -60,6 +60,7 @@ class WheelConfigResponse(BaseModel): can_pay_days: bool = False user_balance_kopeks: int = 0 required_balance_kopeks: int = 0 + has_subscription: bool = False class SpinAvailabilityResponse(BaseModel): diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index 56e8faf1..4d189739 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -5110,10 +5110,11 @@ async def _change_subscription_type(db: AsyncSession, user_id: int, new_type: st old_type = 'триальной' if subscription.is_trial else 'платной' new_type_text = 'триальной' if new_is_trial else 'платной' + was_trial = subscription.is_trial subscription.is_trial = new_is_trial subscription.updated_at = datetime.now(UTC) - if not new_is_trial and subscription.is_trial: + if not new_is_trial and was_trial: user = await get_user_by_id(db, user_id) if user: user.has_had_paid_subscription = True diff --git a/app/handlers/stars_payments.py b/app/handlers/stars_payments.py index 5373e713..08bec177 100644 --- a/app/handlers/stars_payments.py +++ b/app/handlers/stars_payments.py @@ -37,8 +37,38 @@ async def _handle_wheel_spin_payment( ) return False + # Проверяем наличие активной подписки + from app.database.crud.subscription import get_subscription_by_user_id + + subscription = await get_subscription_by_user_id(db, user.id) + if not subscription or not subscription.is_active: + # Конвертируем Stars в баланс как компенсацию + rubles_fallback = TelegramStarsService.calculate_rubles_from_stars(stars_amount) + kopeks_fallback = int((rubles_fallback * Decimal(100)).to_integral_value(rounding=ROUND_HALF_UP)) + from app.database.crud.user import add_user_balance + from app.database.models import TransactionType + + await add_user_balance( + db, + user, + kopeks_fallback, + f'Возврат за спин колеса без подписки ({stars_amount} Stars)', + transaction_type=TransactionType.REFUND, + ) + await db.commit() + await message.answer( + '❌ Для использования колеса удачи необходима активная подписка.\n' + f'💰 {stars_amount} Stars возвращены на баланс в виде {kopeks_fallback / 100:.0f} ₽.', + ) + logger.warning( + 'Wheel spin without subscription, refunded to balance', + user_id=user.id, + stars_amount=stars_amount, + refund_kopeks=kopeks_fallback, + ) + return False + # Выполняем спин напрямую (оплата уже прошла через Stars) - prizes = await get_or_create_wheel_config(db) prizes = await get_wheel_prizes(db, config.id, active_only=True) if not prizes: @@ -64,7 +94,11 @@ async def _handle_wheel_spin_payment( promocode_id = None if generated_promocode: - result = await db.execute(f"SELECT id FROM promocodes WHERE code = '{generated_promocode}'") + from sqlalchemy import text + + result = await db.execute( + text('SELECT id FROM promocodes WHERE code = :code'), {'code': generated_promocode} + ) row = result.fetchone() if row: promocode_id = row[0] diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index f0ea16de..31629f51 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -253,9 +253,11 @@ class SubscriptionService: email=user.email, user_id=user.id, ), - active_internal_squads=subscription.connected_squads, ) + if subscription.connected_squads: + update_kwargs['active_internal_squads'] = subscription.connected_squads + if user_tag is not None: update_kwargs['tag'] = user_tag @@ -298,9 +300,11 @@ class SubscriptionService: email=user.email, user_id=user.id, ), - active_internal_squads=subscription.connected_squads, ) + if subscription.connected_squads: + create_kwargs['active_internal_squads'] = subscription.connected_squads + if user_tag is not None: create_kwargs['tag'] = user_tag @@ -392,9 +396,11 @@ class SubscriptionService: email=user.email, user_id=user.id, ), - active_internal_squads=subscription.connected_squads, ) + if subscription.connected_squads: + update_kwargs['active_internal_squads'] = subscription.connected_squads + if user_tag is not None: update_kwargs['tag'] = user_tag diff --git a/app/services/wheel_service.py b/app/services/wheel_service.py index 24414c10..4b40b6cc 100644 --- a/app/services/wheel_service.py +++ b/app/services/wheel_service.py @@ -550,7 +550,11 @@ class FortuneWheelService: promocode_id = None if generated_promocode: # Получаем ID промокода - result = await db.execute(f"SELECT id FROM promocodes WHERE code = '{generated_promocode}'") + from sqlalchemy import text + + result = await db.execute( + text('SELECT id FROM promocodes WHERE code = :code'), {'code': generated_promocode} + ) row = result.fetchone() if row: promocode_id = row[0]