fix: prevent squad drop on admin subscription type change, require subscription for wheel spins

- 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
This commit is contained in:
Fringg
2026-02-27 00:53:46 +03:00
parent bfef7cc629
commit 59f0e42be7
6 changed files with 70 additions and 7 deletions
+17
View File
@@ -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:
+1
View File
@@ -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):
+2 -1
View File
@@ -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
+36 -2
View File
@@ -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]
+9 -3
View File
@@ -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
+5 -1
View File
@@ -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]