7fb839aef6
- 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 из маппингов
163 lines
6.8 KiB
Python
163 lines
6.8 KiB
Python
import structlog
|
|
from aiogram import Bot, Dispatcher, F, types
|
|
from aiogram.fsm.context import FSMContext
|
|
from aiogram.types import InaccessibleMessage
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.database.models import User
|
|
from app.keyboards.inline import get_back_keyboard
|
|
from app.localization.texts import get_texts
|
|
from app.services.admin_notification_service import AdminNotificationService
|
|
from app.services.promocode_service import PromoCodeService
|
|
from app.states import PromoCodeStates
|
|
from app.utils.decorators import error_handler
|
|
|
|
|
|
logger = structlog.get_logger(__name__)
|
|
|
|
|
|
@error_handler
|
|
async def show_promocode_menu(callback: types.CallbackQuery, db_user: User, state: FSMContext):
|
|
texts = get_texts(db_user.language)
|
|
|
|
# Если сообщение недоступно, отправляем новое
|
|
if isinstance(callback.message, InaccessibleMessage):
|
|
await callback.message.answer(texts.PROMOCODE_ENTER, reply_markup=get_back_keyboard(db_user.language))
|
|
else:
|
|
await callback.message.edit_text(texts.PROMOCODE_ENTER, reply_markup=get_back_keyboard(db_user.language))
|
|
|
|
await state.set_state(PromoCodeStates.waiting_for_code)
|
|
await callback.answer()
|
|
|
|
|
|
async def activate_promocode_for_registration(db: AsyncSession, user_id: int, code: str, bot: Bot = None) -> dict:
|
|
"""
|
|
Активирует промокод для пользователя во время регистрации.
|
|
Возвращает результат активации без отправки сообщений.
|
|
"""
|
|
promocode_service = PromoCodeService()
|
|
result = await promocode_service.activate_promocode(db, user_id, code)
|
|
|
|
if result['success']:
|
|
logger.info('✅ Пользователь активировал промокод при регистрации', user_id=user_id, code=code)
|
|
|
|
# Отправляем уведомление админу, если бот доступен
|
|
if bot:
|
|
try:
|
|
from app.database.crud.user import get_user_by_id
|
|
|
|
user = await get_user_by_id(db, user_id)
|
|
if user:
|
|
notification_service = AdminNotificationService(bot)
|
|
await notification_service.send_promocode_activation_notification(
|
|
db,
|
|
user,
|
|
result.get('promocode', {'code': code}),
|
|
result['description'],
|
|
result.get('balance_before_kopeks'),
|
|
result.get('balance_after_kopeks'),
|
|
)
|
|
except Exception as notify_error:
|
|
logger.error(
|
|
'Ошибка отправки админ уведомления об активации промокода', code=code, notify_error=notify_error
|
|
)
|
|
|
|
return result
|
|
|
|
|
|
@error_handler
|
|
async def process_promocode(message: types.Message, db_user: User, state: FSMContext, db: AsyncSession):
|
|
texts = get_texts(db_user.language)
|
|
|
|
code = message.text.strip()
|
|
|
|
if not code:
|
|
await message.answer(
|
|
texts.t(
|
|
'PROMOCODE_EMPTY_INPUT',
|
|
'❌ Введите корректный промокод',
|
|
),
|
|
reply_markup=get_back_keyboard(db_user.language),
|
|
)
|
|
return
|
|
|
|
from app.utils.promo_rate_limiter import promo_limiter, validate_promo_format
|
|
|
|
# Валидация формата
|
|
if not validate_promo_format(code):
|
|
await message.answer(texts.PROMOCODE_INVALID, reply_markup=get_back_keyboard(db_user.language))
|
|
return
|
|
|
|
# Rate-limit на перебор
|
|
if promo_limiter.is_blocked(message.from_user.id):
|
|
cooldown = promo_limiter.get_block_cooldown(message.from_user.id)
|
|
await message.answer(
|
|
texts.t(
|
|
'PROMO_RATE_LIMITED',
|
|
'⏳ Слишком много попыток. Попробуйте через {cooldown} сек.',
|
|
).format(cooldown=cooldown),
|
|
reply_markup=get_back_keyboard(db_user.language),
|
|
)
|
|
await state.clear()
|
|
return
|
|
|
|
# Лимит на стакинг (макс активаций в день)
|
|
if not promo_limiter.can_activate(message.from_user.id):
|
|
await message.answer(
|
|
texts.t(
|
|
'PROMO_DAILY_LIMIT',
|
|
'❌ Достигнут лимит активаций промокодов на сегодня. Попробуйте завтра.',
|
|
),
|
|
reply_markup=get_back_keyboard(db_user.language),
|
|
)
|
|
await state.clear()
|
|
return
|
|
|
|
result = await activate_promocode_for_registration(db, db_user.id, code, message.bot)
|
|
|
|
if result['success']:
|
|
promo_limiter.record_activation(message.from_user.id)
|
|
await message.answer(
|
|
texts.PROMOCODE_SUCCESS.format(description=result['description']),
|
|
reply_markup=get_back_keyboard(db_user.language),
|
|
)
|
|
else:
|
|
# Записываем неудачную попытку только для not_found (перебор)
|
|
if result['error'] == 'not_found':
|
|
promo_limiter.record_failed_attempt(message.from_user.id)
|
|
promo_limiter.cleanup()
|
|
|
|
error_messages = {
|
|
'not_found': texts.PROMOCODE_INVALID,
|
|
'expired': texts.PROMOCODE_EXPIRED,
|
|
'used': texts.PROMOCODE_USED,
|
|
'already_used_by_user': texts.PROMOCODE_USED,
|
|
'not_first_purchase': texts.t(
|
|
'PROMOCODE_NOT_FIRST_PURCHASE', '❌ Этот промокод доступен только для первой покупки'
|
|
),
|
|
'active_discount_exists': texts.t(
|
|
'PROMOCODE_ACTIVE_DISCOUNT_EXISTS',
|
|
'❌ У вас уже есть активная скидка. Используйте её перед активацией новой.',
|
|
),
|
|
'no_subscription_for_days': texts.t(
|
|
'PROMOCODE_NO_SUBSCRIPTION',
|
|
'❌ Для активации этого промокода необходима подписка (активная или просроченная).',
|
|
),
|
|
'daily_limit': texts.t(
|
|
'PROMO_DAILY_LIMIT',
|
|
'❌ Достигнут лимит активаций промокодов на сегодня. Попробуйте завтра.',
|
|
),
|
|
'server_error': texts.ERROR,
|
|
}
|
|
|
|
error_text = error_messages.get(result['error'], texts.PROMOCODE_INVALID)
|
|
await message.answer(error_text, reply_markup=get_back_keyboard(db_user.language))
|
|
|
|
await state.clear()
|
|
|
|
|
|
def register_handlers(dp: Dispatcher):
|
|
dp.callback_query.register(show_promocode_menu, F.data == 'menu_promocode')
|
|
|
|
dp.message.register(process_promocode, PromoCodeStates.waiting_for_code)
|