From 7fb839aef6234294b95064f9575c19d5a0c3f892 Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 6 Mar 2026 01:33:18 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=BF=D1=80=D0=BE=D0=BC=D0=BE=D0=BA?= =?UTF-8?q?=D0=BE=D0=B4=D1=8B=20=E2=80=94=20=D0=BA=D0=BE=D0=BD=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D1=82=D0=B0=D1=86=D0=B8=D1=8F=20=D1=82=D1=80=D0=B8=D0=B0?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2,=20race=20condition,=20savepoints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - trial подписки теперь конвертируются в платные вместо отказа (ошибка ~20 из 300 юзеров) - extend_subscription: добавлен переход TRIAL→ACTIVE - UniqueConstraint на PromoCodeUse(user_id, promocode_id) + миграция 0015 с дедупликацией - create_promocode_use: begin_nested()+flush() вместо commit/rollback (без коррупции сессии) - race condition: create_promocode_use вызывается ДО _apply_promocode_effects - cleanup: удаление зарезервированной записи при ValueError от эффектов - atomic SQL increment для current_uses (защита от lost-update) - mark_user_as_had_paid_subscription: savepoint вместо commit/rollback - удалён мёртвый код: use_promocode(), trial_subscription_not_eligible из маппингов --- app/cabinet/routes/promocode.py | 1 - app/database/crud/promocode.py | 39 +++++++----------- app/database/crud/subscription.py | 3 ++ app/database/models.py | 3 ++ app/handlers/promocode.py | 4 -- app/services/promocode_service.py | 33 +++++++++++---- app/utils/user_utils.py | 14 +++---- app/webapi/routes/miniapp.py | 2 - ...15_add_promocode_uses_unique_constraint.py | 40 +++++++++++++++++++ 9 files changed, 92 insertions(+), 47 deletions(-) create mode 100644 migrations/alembic/versions/0015_add_promocode_uses_unique_constraint.py diff --git a/app/cabinet/routes/promocode.py b/app/cabinet/routes/promocode.py index 4bf6f392..0f11cacd 100644 --- a/app/cabinet/routes/promocode.py +++ b/app/cabinet/routes/promocode.py @@ -72,7 +72,6 @@ async def activate_promocode( 'already_used_by_user': 'You have already used this promo code', 'active_discount_exists': 'You already have an active discount. Deactivate it first via /deactivate-discount', 'no_subscription_for_days': 'This promo code requires an active or expired subscription', - 'trial_subscription_not_eligible': 'This promo code is not available for trial subscriptions', 'not_first_purchase': 'This promo code is only available for first purchase', 'daily_limit': 'Too many promo code activations today', 'user_not_found': 'User not found', diff --git a/app/database/crud/promocode.py b/app/database/crud/promocode.py index 69b6ad2e..e739d2b1 100644 --- a/app/database/crud/promocode.py +++ b/app/database/crud/promocode.py @@ -84,27 +84,6 @@ async def create_promocode( return promocode -async def use_promocode(db: AsyncSession, promocode_id: int, user_id: int) -> bool: - try: - promocode = await get_promocode_by_id(db, promocode_id) - if not promocode: - return False - - usage = PromoCodeUse(promocode_id=promocode_id, user_id=user_id) - db.add(usage) - - promocode.current_uses += 1 - - await db.commit() - - logger.info('✅ Промокод использован пользователем', code=promocode.code, user_id=user_id) - return True - - except Exception as e: - logger.error('Ошибка использования промокода', error=e) - await db.rollback() - return False - async def check_user_promocode_usage(db: AsyncSession, user_id: int, promocode_id: int) -> bool: result = await db.execute( @@ -113,12 +92,22 @@ async def check_user_promocode_usage(db: AsyncSession, user_id: int, promocode_i return result.scalar_one_or_none() is not None -async def create_promocode_use(db: AsyncSession, promocode_id: int, user_id: int) -> PromoCodeUse: +async def create_promocode_use(db: AsyncSession, promocode_id: int, user_id: int) -> PromoCodeUse | None: + from sqlalchemy.exc import IntegrityError + promocode_use = PromoCodeUse(promocode_id=promocode_id, user_id=user_id, used_at=datetime.now(UTC)) - db.add(promocode_use) - await db.commit() - await db.refresh(promocode_use) + try: + async with db.begin_nested(): + db.add(promocode_use) + await db.flush() + except IntegrityError: + logger.warning( + '⚠️ Дублирующая запись использования промокода (race condition)', + promocode_id=promocode_id, + user_id=user_id, + ) + return None logger.info('📝 Записано использование промокода пользователем', promocode_id=promocode_id, user_id=user_id) return promocode_use diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index 063af465..52dc8159 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -443,6 +443,9 @@ async def extend_subscription( logger.info( '🔄 Статус подписки изменён с на ACTIVE', subscription_id=subscription.id, previous_status=previous_status ) + elif days > 0 and subscription.status == SubscriptionStatus.TRIAL.value: + subscription.status = SubscriptionStatus.ACTIVE.value + logger.info('🔄 Статус подписки изменён с trial на ACTIVE', subscription_id=subscription.id) elif days > 0 and subscription.status == SubscriptionStatus.PENDING.value: logger.warning('⚠️ Попытка продлить PENDING подписку , дни', subscription_id=subscription.id, days=days) diff --git a/app/database/models.py b/app/database/models.py index 030d1cb5..5307cb7f 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -1502,6 +1502,9 @@ class PromoCode(Base): class PromoCodeUse(Base): __tablename__ = 'promocode_uses' + __table_args__ = ( + UniqueConstraint('user_id', 'promocode_id', name='uq_promocode_uses_user_promo'), + ) id = Column(Integer, primary_key=True, index=True) promocode_id = Column(Integer, ForeignKey('promocodes.id'), nullable=False) diff --git a/app/handlers/promocode.py b/app/handlers/promocode.py index 0b680495..3055a4f2 100644 --- a/app/handlers/promocode.py +++ b/app/handlers/promocode.py @@ -143,10 +143,6 @@ async def process_promocode(message: types.Message, db_user: User, state: FSMCon 'PROMOCODE_NO_SUBSCRIPTION', '❌ Для активации этого промокода необходима подписка (активная или просроченная).', ), - 'trial_subscription_not_eligible': texts.t( - 'PROMOCODE_TRIAL_NOT_ELIGIBLE', - '❌ Промокод на дни недоступен для пробной подписки. Оформите платную подписку.', - ), 'daily_limit': texts.t( 'PROMO_DAILY_LIMIT', '❌ Достигнут лимит активаций промокодов на сегодня. Попробуйте завтра.', diff --git a/app/services/promocode_service.py b/app/services/promocode_service.py index 72ac7b2d..bcaa8c5b 100644 --- a/app/services/promocode_service.py +++ b/app/services/promocode_service.py @@ -14,7 +14,7 @@ from app.database.crud.promocode import ( from app.database.crud.subscription import extend_subscription, get_subscription_by_user_id from app.database.crud.user import add_user_balance, get_user_by_id from app.database.crud.user_promo_group import add_user_to_promo_group, has_user_promo_group -from app.database.models import PromoCode, PromoCodeType, User +from app.database.models import PromoCode, PromoCodeType, SubscriptionStatus, User from app.services.remnawave_service import RemnaWaveService from app.services.subscription_service import SubscriptionService @@ -74,14 +74,22 @@ class PromoCodeService: balance_before_kopeks = user.balance_kopeks + # Резервируем запись использования ДО применения эффектов (защита от race condition) + promo_use = await create_promocode_use(db, promocode.id, user_id) + if promo_use is None: + return {'success': False, 'error': 'already_used_by_user'} + try: result_description = await self._apply_promocode_effects(db, user, promocode) except ValueError as e: + # Эффекты не применены — удаляем зарезервированную запись использования + async with db.begin_nested(): + await db.delete(promo_use) + await db.flush() error_key = str(e) if error_key in ( 'active_discount_exists', 'no_subscription_for_days', - 'trial_subscription_not_eligible', ): return {'success': False, 'error': error_key} raise @@ -145,9 +153,13 @@ class PromoCodeService: ) # Don't fail the whole promocode activation if promo group assignment fails - await create_promocode_use(db, promocode.id, user_id) + from sqlalchemy import update as sql_update - promocode.current_uses += 1 + await db.execute( + sql_update(PromoCode) + .where(PromoCode.id == promocode.id) + .values(current_uses=PromoCode.current_uses + 1) + ) await db.commit() logger.info('✅ Пользователь активировал промокод', _format_user_log=self._format_user_log(user), code=code) @@ -248,13 +260,20 @@ class PromoCodeService: if promocode.type == PromoCodeType.SUBSCRIPTION_DAYS.value and promocode.subscription_days > 0: subscription = await get_subscription_by_user_id(db, user.id) - # Промокод на дни работает только для пользователей с НЕтриальной подпиской - # (активной или просроченной). Без подписки или с триалом — отклоняем. if not subscription: raise ValueError('no_subscription_for_days') + # Конвертация триала в платную подписку при активации промокода на дни if subscription.is_trial: - raise ValueError('trial_subscription_not_eligible') + subscription.is_trial = False + if subscription.status == SubscriptionStatus.TRIAL.value: + subscription.status = SubscriptionStatus.ACTIVE.value + subscription.updated_at = datetime.now(UTC) + logger.info( + '🎓 Промокод: конвертация триала в платную подписку', + subscription_id=subscription.id, + code=promocode.code, + ) await extend_subscription(db, subscription, promocode.subscription_days) diff --git a/app/utils/user_utils.py b/app/utils/user_utils.py index e0b90a1b..b59a62a1 100644 --- a/app/utils/user_utils.py +++ b/app/utils/user_utils.py @@ -91,20 +91,18 @@ async def mark_user_as_had_paid_subscription(db: AsyncSession, user: User) -> bo logger.debug('Пользователь уже отмечен как имевший платную подписку', user_id=user.id) return True - await db.execute( - update(User).where(User.id == user.id).values(has_had_paid_subscription=True, updated_at=datetime.now(UTC)) - ) + async with db.begin_nested(): + await db.execute( + update(User) + .where(User.id == user.id) + .values(has_had_paid_subscription=True, updated_at=datetime.now(UTC)) + ) - await db.commit() logger.info('✅ Пользователь отмечен как имевший платную подписку', user_id=user.id) return True except Exception as e: logger.error('Ошибка отметки пользователя как имевшего платную подписку', user_id=user.id, error=e) - try: - await db.rollback() - except Exception as rollback_error: - logger.error('Ошибка отката транзакции', rollback_error=rollback_error) return False diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 2f6bc790..d1118bb0 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -4086,7 +4086,6 @@ async def activate_promo_code( 'used': status.HTTP_409_CONFLICT, 'already_used_by_user': status.HTTP_409_CONFLICT, 'no_subscription_for_days': status.HTTP_400_BAD_REQUEST, - 'trial_subscription_not_eligible': status.HTTP_400_BAD_REQUEST, 'active_discount_exists': status.HTTP_409_CONFLICT, 'not_first_purchase': status.HTTP_400_BAD_REQUEST, 'daily_limit': status.HTTP_429_TOO_MANY_REQUESTS, @@ -4099,7 +4098,6 @@ async def activate_promo_code( 'used': 'Promo code already used', 'already_used_by_user': 'Promo code already used by this user', 'no_subscription_for_days': 'This promo code requires an active or expired subscription', - 'trial_subscription_not_eligible': 'This promo code is not available for trial subscriptions', 'active_discount_exists': 'You already have an active discount', 'not_first_purchase': 'This promo code is only available for first purchase', 'daily_limit': 'Too many promo code activations today', diff --git a/migrations/alembic/versions/0015_add_promocode_uses_unique_constraint.py b/migrations/alembic/versions/0015_add_promocode_uses_unique_constraint.py new file mode 100644 index 00000000..f8053be4 --- /dev/null +++ b/migrations/alembic/versions/0015_add_promocode_uses_unique_constraint.py @@ -0,0 +1,40 @@ +"""add unique constraint on promocode_uses(user_id, promocode_id) + +Revision ID: 0015 +Revises: 0014 +Create Date: 2026-03-06 + +Prevents race condition where concurrent requests could create +duplicate PromoCodeUse records for the same user+promocode. +""" + +from typing import Sequence, Union + +from alembic import op + +revision: str = '0015' +down_revision: Union[str, None] = '0014' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Deduplicate any existing rows before adding constraint + op.execute(""" + DELETE FROM promocode_uses + WHERE id NOT IN ( + SELECT MIN(id) + FROM promocode_uses + GROUP BY user_id, promocode_id + ) + """) + + op.create_unique_constraint( + 'uq_promocode_uses_user_promo', + 'promocode_uses', + ['user_id', 'promocode_id'], + ) + + +def downgrade() -> None: + op.drop_constraint('uq_promocode_uses_user_promo', 'promocode_uses', type_='unique')