mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-03-07 06:23:45 +00:00
fix: промокоды — конвертация триалов, race condition, savepoints
- 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 из маппингов
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
'❌ Достигнут лимит активаций промокодов на сегодня. Попробуйте завтра.',
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
Reference in New Issue
Block a user