From 32da16f652bf9db4cc21061fc92deaf0560f3152 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 19 Sep 2025 12:29:28 +0300 Subject: [PATCH] Refresh user state after campaign bonuses --- app/bot.py | 23 +- app/database/crud/campaign.py | 258 ++++++ app/database/models.py | 73 +- app/handlers/admin/campaigns.py | 805 ++++++++++++++++++ app/handlers/start.py | 159 +++- app/keyboards/admin.py | 53 +- app/localization/texts.py | 14 +- app/services/campaign_service.py | 171 ++++ app/states.py | 9 + .../5d1f1f8b2e9a_add_advertising_campaigns.py | 70 ++ 10 files changed, 1603 insertions(+), 32 deletions(-) create mode 100644 app/database/crud/campaign.py create mode 100644 app/handlers/admin/campaigns.py create mode 100644 app/services/campaign_service.py create mode 100644 migrations/alembic/versions/5d1f1f8b2e9a_add_advertising_campaigns.py diff --git a/app/bot.py b/app/bot.py index aaa30c53..1be1fd65 100644 --- a/app/bot.py +++ b/app/bot.py @@ -19,15 +19,23 @@ from app.handlers import ( referral, support, common ) from app.handlers.admin import ( - main as admin_main, users as admin_users, subscriptions as admin_subscriptions, - promocodes as admin_promocodes, messages as admin_messages, - monitoring as admin_monitoring, referrals as admin_referrals, - rules as admin_rules, remnawave as admin_remnawave, - statistics as admin_statistics, servers as admin_servers, + main as admin_main, + users as admin_users, + subscriptions as admin_subscriptions, + promocodes as admin_promocodes, + messages as admin_messages, + monitoring as admin_monitoring, + referrals as admin_referrals, + rules as admin_rules, + remnawave as admin_remnawave, + statistics as admin_statistics, + servers as admin_servers, maintenance as admin_maintenance, + campaigns as admin_campaigns, user_messages as admin_user_messages, - updates as admin_updates, backup as admin_backup, - welcome_text as admin_welcome_text + updates as admin_updates, + backup as admin_backup, + welcome_text as admin_welcome_text, ) from app.handlers.stars_payments import register_stars_handlers @@ -119,6 +127,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: admin_rules.register_handlers(dp) admin_remnawave.register_handlers(dp) admin_statistics.register_handlers(dp) + admin_campaigns.register_handlers(dp) admin_maintenance.register_handlers(dp) admin_user_messages.register_handlers(dp) admin_updates.register_handlers(dp) diff --git a/app/database/crud/campaign.py b/app/database/crud/campaign.py new file mode 100644 index 00000000..40313434 --- /dev/null +++ b/app/database/crud/campaign.py @@ -0,0 +1,258 @@ +import logging +from datetime import datetime +from typing import Dict, List, Optional + +from sqlalchemy import and_, func, select, update, delete +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.database.models import ( + AdvertisingCampaign, + AdvertisingCampaignRegistration, +) + +logger = logging.getLogger(__name__) + + +async def create_campaign( + db: AsyncSession, + *, + name: str, + start_parameter: str, + bonus_type: str, + created_by: Optional[int] = None, + balance_bonus_kopeks: int = 0, + subscription_duration_days: Optional[int] = None, + subscription_traffic_gb: Optional[int] = None, + subscription_device_limit: Optional[int] = None, + subscription_squads: Optional[List[str]] = None, +) -> AdvertisingCampaign: + campaign = AdvertisingCampaign( + name=name, + start_parameter=start_parameter, + bonus_type=bonus_type, + balance_bonus_kopeks=balance_bonus_kopeks or 0, + subscription_duration_days=subscription_duration_days, + subscription_traffic_gb=subscription_traffic_gb, + subscription_device_limit=subscription_device_limit, + subscription_squads=subscription_squads or [], + created_by=created_by, + is_active=True, + ) + + db.add(campaign) + await db.commit() + await db.refresh(campaign) + + logger.info( + "📣 Создана рекламная кампания %s (start=%s, bonus=%s)", + campaign.name, + campaign.start_parameter, + campaign.bonus_type, + ) + return campaign + + +async def get_campaign_by_id( + db: AsyncSession, campaign_id: int +) -> Optional[AdvertisingCampaign]: + result = await db.execute( + select(AdvertisingCampaign) + .options(selectinload(AdvertisingCampaign.registrations)) + .where(AdvertisingCampaign.id == campaign_id) + ) + return result.scalar_one_or_none() + + +async def get_campaign_by_start_parameter( + db: AsyncSession, + start_parameter: str, + *, + only_active: bool = False, +) -> Optional[AdvertisingCampaign]: + stmt = select(AdvertisingCampaign).where( + AdvertisingCampaign.start_parameter == start_parameter + ) + if only_active: + stmt = stmt.where(AdvertisingCampaign.is_active.is_(True)) + + result = await db.execute(stmt) + return result.scalar_one_or_none() + + +async def get_campaigns_list( + db: AsyncSession, + *, + offset: int = 0, + limit: int = 20, + include_inactive: bool = True, +) -> List[AdvertisingCampaign]: + stmt = ( + select(AdvertisingCampaign) + .options(selectinload(AdvertisingCampaign.registrations)) + .order_by(AdvertisingCampaign.created_at.desc()) + .offset(offset) + .limit(limit) + ) + if not include_inactive: + stmt = stmt.where(AdvertisingCampaign.is_active.is_(True)) + + result = await db.execute(stmt) + return result.scalars().all() + + +async def get_campaigns_count( + db: AsyncSession, *, is_active: Optional[bool] = None +) -> int: + stmt = select(func.count(AdvertisingCampaign.id)) + if is_active is not None: + stmt = stmt.where(AdvertisingCampaign.is_active.is_(is_active)) + + result = await db.execute(stmt) + return result.scalar_one() or 0 + + +async def update_campaign( + db: AsyncSession, + campaign: AdvertisingCampaign, + **kwargs, +) -> AdvertisingCampaign: + allowed_fields = { + "name", + "start_parameter", + "bonus_type", + "balance_bonus_kopeks", + "subscription_duration_days", + "subscription_traffic_gb", + "subscription_device_limit", + "subscription_squads", + "is_active", + } + + update_data = {key: value for key, value in kwargs.items() if key in allowed_fields} + + if not update_data: + return campaign + + update_data["updated_at"] = datetime.utcnow() + + await db.execute( + update(AdvertisingCampaign) + .where(AdvertisingCampaign.id == campaign.id) + .values(**update_data) + ) + await db.commit() + await db.refresh(campaign) + + logger.info("✏️ Обновлена рекламная кампания %s (%s)", campaign.name, update_data) + return campaign + + +async def delete_campaign(db: AsyncSession, campaign: AdvertisingCampaign) -> bool: + await db.execute( + delete(AdvertisingCampaign).where(AdvertisingCampaign.id == campaign.id) + ) + await db.commit() + logger.info("🗑️ Удалена рекламная кампания %s", campaign.name) + return True + + +async def record_campaign_registration( + db: AsyncSession, + *, + campaign_id: int, + user_id: int, + bonus_type: str, + balance_bonus_kopeks: int = 0, + subscription_duration_days: Optional[int] = None, +) -> AdvertisingCampaignRegistration: + existing = await db.execute( + select(AdvertisingCampaignRegistration).where( + and_( + AdvertisingCampaignRegistration.campaign_id == campaign_id, + AdvertisingCampaignRegistration.user_id == user_id, + ) + ) + ) + registration = existing.scalar_one_or_none() + if registration: + return registration + + registration = AdvertisingCampaignRegistration( + campaign_id=campaign_id, + user_id=user_id, + bonus_type=bonus_type, + balance_bonus_kopeks=balance_bonus_kopeks or 0, + subscription_duration_days=subscription_duration_days, + ) + db.add(registration) + await db.commit() + await db.refresh(registration) + + logger.info("📈 Регистрируем пользователя %s в кампании %s", user_id, campaign_id) + return registration + + +async def get_campaign_statistics( + db: AsyncSession, + campaign_id: int, +) -> Dict[str, Optional[int]]: + result = await db.execute( + select( + func.count(AdvertisingCampaignRegistration.id), + func.coalesce( + func.sum(AdvertisingCampaignRegistration.balance_bonus_kopeks), 0 + ), + func.max(AdvertisingCampaignRegistration.created_at), + ).where(AdvertisingCampaignRegistration.campaign_id == campaign_id) + ) + count, total_balance, last_registration = result.one() + + subscription_count_result = await db.execute( + select(func.count(AdvertisingCampaignRegistration.id)).where( + and_( + AdvertisingCampaignRegistration.campaign_id == campaign_id, + AdvertisingCampaignRegistration.bonus_type == "subscription", + ) + ) + ) + + return { + "registrations": count or 0, + "balance_issued": total_balance or 0, + "subscription_issued": subscription_count_result.scalar() or 0, + "last_registration": last_registration, + } + + +async def get_campaigns_overview(db: AsyncSession) -> Dict[str, int]: + total = await get_campaigns_count(db) + active = await get_campaigns_count(db, is_active=True) + inactive = await get_campaigns_count(db, is_active=False) + + registrations_result = await db.execute( + select(func.count(AdvertisingCampaignRegistration.id)) + ) + + balance_result = await db.execute( + select( + func.coalesce( + func.sum(AdvertisingCampaignRegistration.balance_bonus_kopeks), 0 + ) + ) + ) + + subscription_result = await db.execute( + select(func.count(AdvertisingCampaignRegistration.id)).where( + AdvertisingCampaignRegistration.bonus_type == "subscription" + ) + ) + + return { + "total": total, + "active": active, + "inactive": inactive, + "registrations": registrations_result.scalar() or 0, + "balance_total": balance_result.scalar() or 0, + "subscription_total": subscription_result.scalar() or 0, + } diff --git a/app/database/models.py b/app/database/models.py index e12e1eb7..285eeb08 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -3,8 +3,17 @@ from typing import Optional, List from enum import Enum from sqlalchemy import ( - Column, Integer, String, DateTime, Boolean, Text, - ForeignKey, Float, JSON, BigInteger + Column, + Integer, + String, + DateTime, + Boolean, + Text, + ForeignKey, + Float, + JSON, + BigInteger, + UniqueConstraint, ) from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import relationship, Mapped, mapped_column @@ -666,7 +675,7 @@ class UserMessage(Base): class WelcomeText(Base): __tablename__ = "welcome_texts" - + id = Column(Integer, primary_key=True, index=True) text_content = Column(Text, nullable=False) is_active = Column(Boolean, default=True) @@ -674,5 +683,61 @@ class WelcomeText(Base): created_by = Column(Integer, ForeignKey("users.id"), nullable=True) created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) - + creator = relationship("User", backref="created_welcome_texts") + + +class AdvertisingCampaign(Base): + __tablename__ = "advertising_campaigns" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(255), nullable=False) + start_parameter = Column(String(64), nullable=False, unique=True, index=True) + bonus_type = Column(String(20), nullable=False) + + balance_bonus_kopeks = Column(Integer, default=0) + + subscription_duration_days = Column(Integer, nullable=True) + subscription_traffic_gb = Column(Integer, nullable=True) + subscription_device_limit = Column(Integer, nullable=True) + subscription_squads = Column(JSON, default=list) + + is_active = Column(Boolean, default=True) + + created_by = Column(Integer, ForeignKey("users.id"), nullable=True) + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + registrations = relationship("AdvertisingCampaignRegistration", back_populates="campaign") + + @property + def is_balance_bonus(self) -> bool: + return self.bonus_type == "balance" + + @property + def is_subscription_bonus(self) -> bool: + return self.bonus_type == "subscription" + + +class AdvertisingCampaignRegistration(Base): + __tablename__ = "advertising_campaign_registrations" + __table_args__ = ( + UniqueConstraint("campaign_id", "user_id", name="uq_campaign_user"), + ) + + id = Column(Integer, primary_key=True, index=True) + campaign_id = Column(Integer, ForeignKey("advertising_campaigns.id", ondelete="CASCADE"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + + bonus_type = Column(String(20), nullable=False) + balance_bonus_kopeks = Column(Integer, default=0) + subscription_duration_days = Column(Integer, nullable=True) + + created_at = Column(DateTime, default=func.now()) + + campaign = relationship("AdvertisingCampaign", back_populates="registrations") + user = relationship("User") + + @property + def balance_bonus_rubles(self) -> float: + return (self.balance_bonus_kopeks or 0) / 100 diff --git a/app/handlers/admin/campaigns.py b/app/handlers/admin/campaigns.py new file mode 100644 index 00000000..68708b67 --- /dev/null +++ b/app/handlers/admin/campaigns.py @@ -0,0 +1,805 @@ +import logging +import re +from typing import List + +from aiogram import Dispatcher, types, F +from aiogram.fsm.context import FSMContext +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.crud.campaign import ( + create_campaign, + delete_campaign, + get_campaign_by_id, + get_campaign_by_start_parameter, + get_campaign_statistics, + get_campaigns_count, + get_campaigns_list, + get_campaigns_overview, + update_campaign, +) +from app.database.crud.server_squad import get_all_server_squads, get_server_squad_by_id +from app.database.models import User +from app.keyboards.admin import ( + get_admin_campaigns_keyboard, + get_admin_pagination_keyboard, + get_campaign_bonus_type_keyboard, + get_campaign_management_keyboard, + get_confirmation_keyboard, +) +from app.localization.texts import get_texts +from app.states import AdminStates +from app.utils.decorators import admin_required, error_handler + +logger = logging.getLogger(__name__) + +_CAMPAIGN_PARAM_REGEX = re.compile(r"^[A-Za-z0-9_-]{3,32}$") +_CAMPAIGNS_PAGE_SIZE = 5 + + +def _format_campaign_summary(campaign, texts) -> str: + status = "🟢 Активна" if campaign.is_active else "⚪️ Выключена" + + if campaign.is_balance_bonus: + bonus_text = texts.format_price(campaign.balance_bonus_kopeks) + bonus_info = f"💰 Бонус на баланс: {bonus_text}" + else: + traffic_text = texts.format_traffic(campaign.subscription_traffic_gb or 0) + bonus_info = ( + "📱 Подписка: {days} д.\n" + "🌐 Трафик: {traffic}\n" + "📱 Устройства: {devices}" + ).format( + days=campaign.subscription_duration_days or 0, + traffic=traffic_text, + devices=campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT, + ) + + return ( + f"{campaign.name}\n" + f"Стартовый параметр: {campaign.start_parameter}\n" + f"Статус: {status}\n" + f"{bonus_info}\n" + ) + + +async def _get_bot_deep_link( + callback: types.CallbackQuery, start_parameter: str +) -> str: + bot = await callback.bot.get_me() + return f"https://t.me/{bot.username}?start={start_parameter}" + + +async def _get_bot_deep_link_from_message( + message: types.Message, start_parameter: str +) -> str: + bot = await message.bot.get_me() + return f"https://t.me/{bot.username}?start={start_parameter}" + + +def _build_campaign_servers_keyboard( + servers, selected_uuids: List[str] +) -> types.InlineKeyboardMarkup: + keyboard: List[List[types.InlineKeyboardButton]] = [] + + for server in servers[:20]: + is_selected = server.squad_uuid in selected_uuids + emoji = "✅" if is_selected else ("⚪" if server.is_available else "🔒") + text = f"{emoji} {server.display_name}" + keyboard.append( + [ + types.InlineKeyboardButton( + text=text, callback_data=f"campaign_toggle_server_{server.id}" + ) + ] + ) + + keyboard.append( + [ + types.InlineKeyboardButton( + text="✅ Сохранить", callback_data="campaign_servers_save" + ), + types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_campaigns"), + ] + ) + + return types.InlineKeyboardMarkup(inline_keyboard=keyboard) + + +@admin_required +@error_handler +async def show_campaigns_menu( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + overview = await get_campaigns_overview(db) + + text = ( + "📣 Рекламные кампании\n\n" + f"Всего кампаний: {overview['total']}\n" + f"Активных: {overview['active']} | Выключены: {overview['inactive']}\n" + f"Регистраций: {overview['registrations']}\n" + f"Выдано баланса: {texts.format_price(overview['balance_total'])}\n" + f"Выдано подписок: {overview['subscription_total']}" + ) + + await callback.message.edit_text( + text, + reply_markup=get_admin_campaigns_keyboard(db_user.language), + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_campaigns_overall_stats( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + overview = await get_campaigns_overview(db) + + text = ["📊 Общая статистика кампаний\n"] + text.append(f"Всего кампаний: {overview['total']}") + text.append( + f"Активны: {overview['active']}, выключены: {overview['inactive']}" + ) + text.append(f"Всего регистраций: {overview['registrations']}") + text.append( + f"Суммарно выдано баланса: {texts.format_price(overview['balance_total'])}" + ) + text.append(f"Выдано подписок: {overview['subscription_total']}") + + await callback.message.edit_text( + "\n".join(text), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="⬅️ Назад", callback_data="admin_campaigns" + ) + ] + ] + ), + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_campaigns_list( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + + page = 1 + if callback.data.startswith("admin_campaigns_list_page_"): + try: + page = int(callback.data.split("_")[-1]) + except ValueError: + page = 1 + + offset = (page - 1) * _CAMPAIGNS_PAGE_SIZE + campaigns = await get_campaigns_list( + db, + offset=offset, + limit=_CAMPAIGNS_PAGE_SIZE, + ) + total = await get_campaigns_count(db) + total_pages = max(1, (total + _CAMPAIGNS_PAGE_SIZE - 1) // _CAMPAIGNS_PAGE_SIZE) + + if not campaigns: + await callback.message.edit_text( + "❌ Рекламные кампании не найдены.", + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="➕ Создать", callback_data="admin_campaigns_create" + ) + ], + [ + types.InlineKeyboardButton( + text="⬅️ Назад", callback_data="admin_campaigns" + ) + ], + ] + ), + ) + await callback.answer() + return + + text_lines = ["📋 Список кампаний\n"] + + for campaign in campaigns: + registrations = len(campaign.registrations or []) + total_balance = sum( + r.balance_bonus_kopeks or 0 for r in campaign.registrations or [] + ) + status = "🟢" if campaign.is_active else "⚪" + line = ( + f"{status} {campaign.name}{campaign.start_parameter}\n" + f" Регистраций: {registrations}, баланс: {texts.format_price(total_balance)}" + ) + if campaign.is_subscription_bonus: + line += f", подписка: {campaign.subscription_duration_days or 0} д." + else: + line += ", бонус: баланс" + text_lines.append(line) + + keyboard_rows = [ + [ + types.InlineKeyboardButton( + text=f"🔍 {campaign.name}", + callback_data=f"admin_campaign_manage_{campaign.id}", + ) + ] + for campaign in campaigns + ] + + pagination = get_admin_pagination_keyboard( + current_page=page, + total_pages=total_pages, + callback_prefix="admin_campaigns_list", + back_callback="admin_campaigns", + language=db_user.language, + ) + + keyboard_rows.extend(pagination.inline_keyboard) + + await callback.message.edit_text( + "\n".join(text_lines), + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows), + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_campaign_detail( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + campaign_id = int(callback.data.split("_")[-1]) + campaign = await get_campaign_by_id(db, campaign_id) + + if not campaign: + await callback.answer("❌ Кампания не найдена", show_alert=True) + return + + texts = get_texts(db_user.language) + stats = await get_campaign_statistics(db, campaign_id) + deep_link = await _get_bot_deep_link(callback, campaign.start_parameter) + + text = ["📣 Управление кампанией\n"] + text.append(_format_campaign_summary(campaign, texts)) + text.append(f"🔗 Ссылка: {deep_link}") + text.append("\n📊 Статистика") + text.append(f"• Регистраций: {stats['registrations']}") + text.append( + f"• Выдано баланса: {texts.format_price(stats['balance_issued'])}" + ) + text.append(f"• Выдано подписок: {stats['subscription_issued']}") + if stats["last_registration"]: + text.append( + f"• Последняя: {stats['last_registration'].strftime('%d.%m.%Y %H:%M')}" + ) + + await callback.message.edit_text( + "\n".join(text), + reply_markup=get_campaign_management_keyboard( + campaign.id, campaign.is_active, db_user.language + ), + ) + await callback.answer() + + +@admin_required +@error_handler +async def toggle_campaign_status( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + campaign_id = int(callback.data.split("_")[-1]) + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await callback.answer("❌ Кампания не найдена", show_alert=True) + return + + new_status = not campaign.is_active + await update_campaign(db, campaign, is_active=new_status) + status_text = "включена" if new_status else "выключена" + logger.info("🔄 Кампания %s переключена: %s", campaign_id, status_text) + + await show_campaign_detail(callback, db_user, db) + + +@admin_required +@error_handler +async def show_campaign_stats( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + campaign_id = int(callback.data.split("_")[-1]) + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await callback.answer("❌ Кампания не найдена", show_alert=True) + return + + texts = get_texts(db_user.language) + stats = await get_campaign_statistics(db, campaign_id) + + text = ["📊 Статистика кампании\n"] + text.append(_format_campaign_summary(campaign, texts)) + text.append(f"Регистраций: {stats['registrations']}") + text.append(f"Выдано баланса: {texts.format_price(stats['balance_issued'])}") + text.append(f"Выдано подписок: {stats['subscription_issued']}") + if stats["last_registration"]: + text.append( + f"Последняя регистрация: {stats['last_registration'].strftime('%d.%m.%Y %H:%M')}" + ) + + await callback.message.edit_text( + "\n".join(text), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="⬅️ Назад", + callback_data=f"admin_campaign_manage_{campaign_id}", + ) + ] + ] + ), + ) + await callback.answer() + + +@admin_required +@error_handler +async def confirm_delete_campaign( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + campaign_id = int(callback.data.split("_")[-1]) + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await callback.answer("❌ Кампания не найдена", show_alert=True) + return + + text = ( + "🗑️ Удаление кампании\n\n" + f"Название: {campaign.name}\n" + f"Параметр: {campaign.start_parameter}\n\n" + "Вы уверены, что хотите удалить кампанию?" + ) + + await callback.message.edit_text( + text, + reply_markup=get_confirmation_keyboard( + confirm_callback=f"admin_campaign_delete_confirm_{campaign_id}", + cancel_callback=f"admin_campaign_manage_{campaign_id}", + ), + ) + await callback.answer() + + +@admin_required +@error_handler +async def delete_campaign_confirmed( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + campaign_id = int(callback.data.split("_")[-1]) + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign: + await callback.answer("❌ Кампания не найдена", show_alert=True) + return + + await delete_campaign(db, campaign) + await callback.message.edit_text( + "✅ Кампания удалена.", + reply_markup=get_admin_campaigns_keyboard(db_user.language), + ) + await callback.answer("Удалено") + + +@admin_required +@error_handler +async def start_campaign_creation( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + await state.clear() + await callback.message.edit_text( + "🆕 Создание рекламной кампании\n\nВведите название кампании:", + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="⬅️ Назад", callback_data="admin_campaigns" + ) + ] + ] + ), + ) + await state.set_state(AdminStates.creating_campaign_name) + await callback.answer() + + +@admin_required +@error_handler +async def process_campaign_name( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + name = message.text.strip() + if len(name) < 3 or len(name) > 100: + await message.answer( + "❌ Название должно содержать от 3 до 100 символов. Попробуйте снова." + ) + return + + await state.update_data(campaign_name=name) + await state.set_state(AdminStates.creating_campaign_start) + await message.answer( + "🔗 Теперь введите параметр старта (латинские буквы, цифры, - или _):", + ) + + +@admin_required +@error_handler +async def process_campaign_start_parameter( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + start_param = message.text.strip() + if not _CAMPAIGN_PARAM_REGEX.match(start_param): + await message.answer( + "❌ Разрешены только латинские буквы, цифры, символы - и _. Длина 3-32 символа." + ) + return + + existing = await get_campaign_by_start_parameter(db, start_param) + if existing: + await message.answer( + "❌ Кампания с таким параметром уже существует. Введите другой параметр." + ) + return + + await state.update_data(campaign_start_parameter=start_param) + await state.set_state(AdminStates.creating_campaign_bonus) + await message.answer( + "🎯 Выберите тип бонуса для кампании:", + reply_markup=get_campaign_bonus_type_keyboard(db_user.language), + ) + + +@admin_required +@error_handler +async def select_campaign_bonus_type( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + bonus_type = "balance" if callback.data.endswith("balance") else "subscription" + await state.update_data(campaign_bonus_type=bonus_type) + + if bonus_type == "balance": + await state.set_state(AdminStates.creating_campaign_balance) + await callback.message.edit_text( + "💰 Введите сумму бонуса на баланс (в рублях):", + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="⬅️ Назад", callback_data="admin_campaigns" + ) + ] + ] + ), + ) + else: + await state.set_state(AdminStates.creating_campaign_subscription_days) + await callback.message.edit_text( + "📅 Введите длительность подписки в днях (1-730):", + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="⬅️ Назад", callback_data="admin_campaigns" + ) + ] + ] + ), + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_campaign_balance_value( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + try: + amount_rubles = float(message.text.replace(",", ".")) + except ValueError: + await message.answer("❌ Введите корректную сумму (например, 100 или 99.5)") + return + + if amount_rubles <= 0: + await message.answer("❌ Сумма должна быть больше нуля") + return + + amount_kopeks = int(round(amount_rubles * 100)) + data = await state.get_data() + + campaign = await create_campaign( + db, + name=data["campaign_name"], + start_parameter=data["campaign_start_parameter"], + bonus_type="balance", + balance_bonus_kopeks=amount_kopeks, + created_by=db_user.id, + ) + + await state.clear() + + deep_link = await _get_bot_deep_link_from_message(message, campaign.start_parameter) + texts = get_texts(db_user.language) + summary = _format_campaign_summary(campaign, texts) + text = ( + "✅ Кампания создана!\n\n" + f"{summary}\n" + f"🔗 Ссылка: {deep_link}" + ) + + await message.answer( + text, + reply_markup=get_campaign_management_keyboard( + campaign.id, campaign.is_active, db_user.language + ), + ) + + +@admin_required +@error_handler +async def process_campaign_subscription_days( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + try: + days = int(message.text.strip()) + except ValueError: + await message.answer("❌ Введите число дней (1-730)") + return + + if days <= 0 or days > 730: + await message.answer("❌ Длительность должна быть от 1 до 730 дней") + return + + await state.update_data(campaign_subscription_days=days) + await state.set_state(AdminStates.creating_campaign_subscription_traffic) + await message.answer("🌐 Введите лимит трафика в ГБ (0 = безлимит):") + + +@admin_required +@error_handler +async def process_campaign_subscription_traffic( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + try: + traffic = int(message.text.strip()) + except ValueError: + await message.answer("❌ Введите целое число (0 или больше)") + return + + if traffic < 0 or traffic > 10000: + await message.answer("❌ Лимит трафика должен быть от 0 до 10000 ГБ") + return + + await state.update_data(campaign_subscription_traffic=traffic) + await state.set_state(AdminStates.creating_campaign_subscription_devices) + await message.answer( + f"📱 Введите количество устройств (1-{settings.MAX_DEVICES_LIMIT}):" + ) + + +@admin_required +@error_handler +async def process_campaign_subscription_devices( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + try: + devices = int(message.text.strip()) + except ValueError: + await message.answer("❌ Введите целое число устройств") + return + + if devices < 1 or devices > settings.MAX_DEVICES_LIMIT: + await message.answer( + f"❌ Количество устройств должно быть от 1 до {settings.MAX_DEVICES_LIMIT}" + ) + return + + await state.update_data(campaign_subscription_devices=devices) + await state.update_data(campaign_subscription_squads=[]) + await state.set_state(AdminStates.creating_campaign_subscription_servers) + + servers, _ = await get_all_server_squads(db, available_only=False) + if not servers: + await message.answer( + "❌ Не найдены доступные серверы. Добавьте сервера перед созданием кампании.", + ) + await state.clear() + return + + keyboard = _build_campaign_servers_keyboard(servers, []) + await message.answer( + "🌍 Выберите серверы, которые будут доступны по подписке (максимум 20 отображаются).", + reply_markup=keyboard, + ) + + +@admin_required +@error_handler +async def toggle_campaign_server( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + server_id = int(callback.data.split("_")[-1]) + server = await get_server_squad_by_id(db, server_id) + if not server: + await callback.answer("❌ Сервер не найден", show_alert=True) + return + + data = await state.get_data() + selected = list(data.get("campaign_subscription_squads", [])) + + if server.squad_uuid in selected: + selected.remove(server.squad_uuid) + else: + selected.append(server.squad_uuid) + + await state.update_data(campaign_subscription_squads=selected) + + servers, _ = await get_all_server_squads(db, available_only=False) + keyboard = _build_campaign_servers_keyboard(servers, selected) + + await callback.message.edit_reply_markup(reply_markup=keyboard) + await callback.answer() + + +@admin_required +@error_handler +async def finalize_campaign_subscription( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + data = await state.get_data() + selected = data.get("campaign_subscription_squads", []) + + if not selected: + await callback.answer("❗ Выберите хотя бы один сервер", show_alert=True) + return + + campaign = await create_campaign( + db, + name=data["campaign_name"], + start_parameter=data["campaign_start_parameter"], + bonus_type="subscription", + subscription_duration_days=data.get("campaign_subscription_days"), + subscription_traffic_gb=data.get("campaign_subscription_traffic"), + subscription_device_limit=data.get("campaign_subscription_devices"), + subscription_squads=selected, + created_by=db_user.id, + ) + + await state.clear() + + deep_link = await _get_bot_deep_link(callback, campaign.start_parameter) + texts = get_texts(db_user.language) + summary = _format_campaign_summary(campaign, texts) + text = ( + "✅ Кампания создана!\n\n" + f"{summary}\n" + f"🔗 Ссылка: {deep_link}" + ) + + await callback.message.edit_text( + text, + reply_markup=get_campaign_management_keyboard( + campaign.id, campaign.is_active, db_user.language + ), + ) + await callback.answer() + + +def register_handlers(dp: Dispatcher): + dp.callback_query.register(show_campaigns_menu, F.data == "admin_campaigns") + dp.callback_query.register( + show_campaigns_overall_stats, F.data == "admin_campaigns_stats" + ) + dp.callback_query.register(show_campaigns_list, F.data == "admin_campaigns_list") + dp.callback_query.register( + show_campaigns_list, F.data.startswith("admin_campaigns_list_page_") + ) + dp.callback_query.register( + start_campaign_creation, F.data == "admin_campaigns_create" + ) + dp.callback_query.register( + show_campaign_stats, F.data.startswith("admin_campaign_stats_") + ) + dp.callback_query.register( + show_campaign_detail, F.data.startswith("admin_campaign_manage_") + ) + dp.callback_query.register( + delete_campaign_confirmed, F.data.startswith("admin_campaign_delete_confirm_") + ) + dp.callback_query.register( + confirm_delete_campaign, F.data.startswith("admin_campaign_delete_") + ) + dp.callback_query.register( + toggle_campaign_status, F.data.startswith("admin_campaign_toggle_") + ) + dp.callback_query.register( + finalize_campaign_subscription, F.data == "campaign_servers_save" + ) + dp.callback_query.register( + toggle_campaign_server, F.data.startswith("campaign_toggle_server_") + ) + dp.callback_query.register( + select_campaign_bonus_type, F.data.startswith("campaign_bonus_") + ) + + dp.message.register(process_campaign_name, AdminStates.creating_campaign_name) + dp.message.register( + process_campaign_start_parameter, AdminStates.creating_campaign_start + ) + dp.message.register( + process_campaign_balance_value, AdminStates.creating_campaign_balance + ) + dp.message.register( + process_campaign_subscription_days, + AdminStates.creating_campaign_subscription_days, + ) + dp.message.register( + process_campaign_subscription_traffic, + AdminStates.creating_campaign_subscription_traffic, + ) + dp.message.register( + process_campaign_subscription_devices, + AdminStates.creating_campaign_subscription_devices, + ) diff --git a/app/handlers/start.py b/app/handlers/start.py index b33ced5f..312010df 100644 --- a/app/handlers/start.py +++ b/app/handlers/start.py @@ -9,7 +9,13 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.states import RegistrationStates from app.database.crud.user import ( - get_user_by_telegram_id, create_user, get_user_by_referral_code + get_user_by_telegram_id, + create_user, + get_user_by_referral_code, +) +from app.database.crud.campaign import ( + get_campaign_by_start_parameter, + get_campaign_by_id, ) from app.database.models import UserStatus from app.keyboards.inline import ( @@ -17,15 +23,52 @@ from app.keyboards.inline import ( ) from app.localization.texts import get_texts from app.services.referral_service import process_referral_registration +from app.services.campaign_service import AdvertisingCampaignService from app.utils.user_utils import generate_unique_referral_code from app.database.crud.user_message import get_random_active_message -from aiogram.enums import ChatMemberStatus -from aiogram.exceptions import TelegramForbiddenError, TelegramBadRequest logger = logging.getLogger(__name__) +async def _apply_campaign_bonus_if_needed( + db: AsyncSession, + user, + state_data: dict, + texts, +): + campaign_id = state_data.get("campaign_id") if state_data else None + if not campaign_id: + return None + + campaign = await get_campaign_by_id(db, campaign_id) + if not campaign or not campaign.is_active: + return None + + service = AdvertisingCampaignService() + result = await service.apply_campaign_bonus(db, user, campaign) + if not result.success: + return None + + if result.bonus_type == "balance": + amount_text = texts.format_price(result.balance_kopeks) + return texts.CAMPAIGN_BONUS_BALANCE.format( + amount=amount_text, + name=campaign.name, + ) + + if result.bonus_type == "subscription": + traffic_text = texts.format_traffic(result.subscription_traffic_gb or 0) + return texts.CAMPAIGN_BONUS_SUBSCRIPTION.format( + name=campaign.name, + days=result.subscription_days, + traffic=traffic_text, + devices=result.subscription_device_limit, + ) + + return None + + async def handle_potential_referral_code( message: types.Message, state: FSMContext, @@ -86,13 +129,29 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, logger.info(f"🚀 START: Обработка /start от {message.from_user.id}") referral_code = None - if len(message.text.split()) > 1: - potential_code = message.text.split()[1] - referral_code = potential_code - logger.info(f"🔎 Найден реферальный код: {referral_code}") - + campaign = None + start_args = message.text.split() + if len(start_args) > 1: + start_parameter = start_args[1] + campaign = await get_campaign_by_start_parameter( + db, + start_parameter, + only_active=True, + ) + + if campaign: + logger.info( + "📣 Найдена рекламная кампания %s (start=%s)", + campaign.id, + campaign.start_parameter, + ) + await state.update_data(campaign_id=campaign.id) + else: + referral_code = start_parameter + logger.info(f"🔎 Найден реферальный код: {referral_code}") + if referral_code: - await state.set_data({'referral_code': referral_code}) + await state.update_data(referral_code=referral_code) user = db_user if db_user else await get_user_by_telegram_id(db, message.from_user.id) @@ -130,9 +189,19 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, await db.commit() texts = get_texts(user.language) - + if referral_code and not user.referred_by_id: - await message.answer("ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.") + await message.answer( + "ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена." + ) + + if campaign: + try: + await message.answer(texts.CAMPAIGN_EXISTING_USER) + except Exception as e: + logger.error( + f"Ошибка отправки уведомления о рекламной кампании: {e}" + ) has_active_subscription = user.subscription is not None subscription_is_active = False @@ -533,9 +602,35 @@ async def complete_registration_from_callback( logger.info(f"✅ Реферальная регистрация обработана для {user.id}") except Exception as e: logger.error(f"Ошибка при обработке реферальной регистрации: {e}") - + + campaign_message = await _apply_campaign_bonus_if_needed(db, user, data, texts) + + try: + await db.refresh(user) + except Exception as refresh_error: + logger.error( + "Ошибка обновления данных пользователя %s после бонуса кампании: %s", + user.telegram_id, + refresh_error, + ) + + try: + await db.refresh(user, ["subscription"]) + except Exception as refresh_subscription_error: + logger.error( + "Ошибка обновления подписки пользователя %s после бонуса кампании: %s", + user.telegram_id, + refresh_subscription_error, + ) + await state.clear() + if campaign_message: + try: + await callback.message.answer(campaign_message) + except Exception as e: + logger.error(f"Ошибка отправки сообщения о бонусе кампании: {e}") + from app.database.crud.welcome_text import get_welcome_text_for_user offer_text = await get_welcome_text_for_user(db, callback.from_user) @@ -551,10 +646,10 @@ async def complete_registration_from_callback( else: logger.info(f"ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя {user.telegram_id}") - has_active_subscription = user.subscription is not None + has_active_subscription = bool(getattr(user, "subscription", None)) subscription_is_active = False - - if user.subscription: + + if getattr(user, "subscription", None): subscription_is_active = user.subscription.is_active menu_text = await get_main_menu_text(user, texts, db) @@ -698,9 +793,35 @@ async def complete_registration( logger.info(f"✅ Реферальная регистрация обработана для {user.id}") except Exception as e: logger.error(f"Ошибка при обработке реферальной регистрации: {e}") - + + campaign_message = await _apply_campaign_bonus_if_needed(db, user, data, texts) + + try: + await db.refresh(user) + except Exception as refresh_error: + logger.error( + "Ошибка обновления данных пользователя %s после бонуса кампании: %s", + user.telegram_id, + refresh_error, + ) + + try: + await db.refresh(user, ["subscription"]) + except Exception as refresh_subscription_error: + logger.error( + "Ошибка обновления подписки пользователя %s после бонуса кампании: %s", + user.telegram_id, + refresh_subscription_error, + ) + await state.clear() + if campaign_message: + try: + await message.answer(campaign_message) + except Exception as e: + logger.error(f"Ошибка отправки сообщения о бонусе кампании: {e}") + from app.database.crud.welcome_text import get_welcome_text_for_user offer_text = await get_welcome_text_for_user(db, message.from_user) @@ -716,10 +837,10 @@ async def complete_registration( else: logger.info(f"ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя {user.telegram_id}") - has_active_subscription = user.subscription is not None + has_active_subscription = bool(getattr(user, "subscription", None)) subscription_is_active = False - - if user.subscription: + + if getattr(user, "subscription", None): subscription_is_active = user.subscription.is_active menu_text = await get_main_menu_text(user, texts, db) diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index 067c1e83..d49daf82 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -36,12 +36,15 @@ def get_admin_users_submenu_keyboard(language: str = "ru") -> InlineKeyboardMark def get_admin_promo_submenu_keyboard(language: str = "ru") -> InlineKeyboardMarkup: texts = get_texts(language) - + return InlineKeyboardMarkup(inline_keyboard=[ [ InlineKeyboardButton(text=texts.ADMIN_PROMOCODES, callback_data="admin_promocodes"), InlineKeyboardButton(text=texts.ADMIN_STATISTICS, callback_data="admin_statistics") ], + [ + InlineKeyboardButton(text=texts.ADMIN_CAMPAIGNS, callback_data="admin_campaigns") + ], [ InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel") ] @@ -147,6 +150,54 @@ def get_admin_promocodes_keyboard(language: str = "ru") -> InlineKeyboardMarkup: ]) +def get_admin_campaigns_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text="📋 Список кампаний", callback_data="admin_campaigns_list"), + InlineKeyboardButton(text="➕ Создать", callback_data="admin_campaigns_create") + ], + [ + InlineKeyboardButton(text="📊 Общая статистика", callback_data="admin_campaigns_stats") + ], + [ + InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_promo") + ] + ]) + + +def get_campaign_management_keyboard(campaign_id: int, is_active: bool, language: str = "ru") -> InlineKeyboardMarkup: + status_text = "🔴 Выключить" if is_active else "🟢 Включить" + + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text="📊 Статистика", callback_data=f"admin_campaign_stats_{campaign_id}"), + InlineKeyboardButton(text=status_text, callback_data=f"admin_campaign_toggle_{campaign_id}") + ], + [ + InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_campaign_delete_{campaign_id}") + ], + [ + InlineKeyboardButton(text="⬅️ К списку", callback_data="admin_campaigns_list") + ] + ]) + + +def get_campaign_bonus_type_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text="💰 Бонус на баланс", callback_data="campaign_bonus_balance"), + InlineKeyboardButton(text="📱 Подписка", callback_data="campaign_bonus_subscription") + ], + [ + InlineKeyboardButton(text=texts.BACK, callback_data="admin_campaigns") + ] + ]) + + def get_promocode_management_keyboard(promo_id: int, language: str = "ru") -> InlineKeyboardMarkup: return InlineKeyboardMarkup(inline_keyboard=[ [ diff --git a/app/localization/texts.py b/app/localization/texts.py index 4ad43c7c..2984f370 100644 --- a/app/localization/texts.py +++ b/app/localization/texts.py @@ -312,8 +312,15 @@ class RussianTexts(Texts): 💪 Быстро, надежно, недорого! """ - + CREATE_INVITE = "📝 Создать приглашение" + CAMPAIGN_EXISTING_USER = ( + "ℹ️ Эта рекламная ссылка доступна только новым пользователям." + ) + CAMPAIGN_BONUS_BALANCE = ( + "🎉 Вы получили {amount} за регистрацию по кампании «{name}»!" + ) + CAMPAIGN_BONUS_SUBSCRIPTION = "🎉 Вам выдана подписка на {days} д. (трафик: {traffic}, устройств: {devices}) по кампании «{name}»!" TRIAL_ENDING_SOON = """ 🎁 Тестовая подписка скоро закончится! @@ -421,6 +428,7 @@ class RussianTexts(Texts): ADMIN_USERS = "👥 Пользователи" ADMIN_SUBSCRIPTIONS = "📱 Подписки" ADMIN_PROMOCODES = "🎫 Промокоды" + ADMIN_CAMPAIGNS = "📣 Рекламные кампании" ADMIN_MESSAGES = "📨 Рассылки" ADMIN_MONITORING = "🔍 Мониторинг" ADMIN_REFERRALS = "🤝 Партнерка" @@ -535,6 +543,7 @@ To get started, select interface language: CONTINUE = "➡️ Continue" YES = "✅ Yes" NO = "❌ No" + ADMIN_CAMPAIGNS = "📣 Campaigns" MENU_BALANCE = "💰 Balance" MENU_SUBSCRIPTION = "📱 Subscription" @@ -545,6 +554,9 @@ To get started, select interface language: GO_TO_BALANCE_TOP_UP = "💳 Go to balance top up" RETURN_TO_SUBSCRIPTION_CHECKOUT = "↩️ Back to checkout" NO_SAVED_SUBSCRIPTION_ORDER = "❌ Saved subscription order not found. Please configure it again." + CAMPAIGN_EXISTING_USER = "ℹ️ This campaign link is available for new users only." + CAMPAIGN_BONUS_BALANCE = "🎉 You received {amount} for joining via campaign “{name}”!" + CAMPAIGN_BONUS_SUBSCRIPTION = "🎉 You received a {days}-day subscription (traffic: {traffic}, devices: {devices}) from campaign “{name}”!" LANGUAGES = { diff --git a/app/services/campaign_service.py b/app/services/campaign_service.py new file mode 100644 index 00000000..8fecc497 --- /dev/null +++ b/app/services/campaign_service.py @@ -0,0 +1,171 @@ +import logging +from dataclasses import dataclass +from typing import List, Optional + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.crud.campaign import record_campaign_registration +from app.database.crud.subscription import ( + create_paid_subscription, + get_subscription_by_user_id, +) +from app.database.crud.user import add_user_balance +from app.database.models import AdvertisingCampaign, User +from app.services.subscription_service import SubscriptionService + +logger = logging.getLogger(__name__) + + +@dataclass +class CampaignBonusResult: + success: bool + bonus_type: Optional[str] = None + balance_kopeks: int = 0 + subscription_days: Optional[int] = None + subscription_traffic_gb: Optional[int] = None + subscription_device_limit: Optional[int] = None + subscription_squads: Optional[List[str]] = None + + +class AdvertisingCampaignService: + def __init__(self) -> None: + self.subscription_service = SubscriptionService() + + async def apply_campaign_bonus( + self, + db: AsyncSession, + user: User, + campaign: AdvertisingCampaign, + ) -> CampaignBonusResult: + if not campaign.is_active: + logger.warning( + "⚠️ Попытка выдать бонус по неактивной кампании %s", campaign.id + ) + return CampaignBonusResult(success=False) + + if campaign.is_balance_bonus: + return await self._apply_balance_bonus(db, user, campaign) + + if campaign.is_subscription_bonus: + return await self._apply_subscription_bonus(db, user, campaign) + + logger.error("❌ Неизвестный тип бонуса кампании: %s", campaign.bonus_type) + return CampaignBonusResult(success=False) + + async def _apply_balance_bonus( + self, + db: AsyncSession, + user: User, + campaign: AdvertisingCampaign, + ) -> CampaignBonusResult: + amount = campaign.balance_bonus_kopeks or 0 + if amount <= 0: + logger.info("ℹ️ Кампания %s не имеет бонуса на баланс", campaign.id) + return CampaignBonusResult(success=False) + + description = f"Бонус за регистрацию по кампании '{campaign.name}'" + success = await add_user_balance( + db, + user, + amount, + description=description, + ) + + if not success: + return CampaignBonusResult(success=False) + + await record_campaign_registration( + db, + campaign_id=campaign.id, + user_id=user.id, + bonus_type="balance", + balance_bonus_kopeks=amount, + ) + + logger.info( + "💰 Пользователю %s начислен бонус %s₽ по кампании %s", + user.telegram_id, + amount / 100, + campaign.id, + ) + + return CampaignBonusResult( + success=True, + bonus_type="balance", + balance_kopeks=amount, + ) + + async def _apply_subscription_bonus( + self, + db: AsyncSession, + user: User, + campaign: AdvertisingCampaign, + ) -> CampaignBonusResult: + existing_subscription = await get_subscription_by_user_id(db, user.id) + if existing_subscription: + logger.warning( + "⚠️ У пользователя %s уже есть подписка, бонус кампании %s пропущен", + user.telegram_id, + campaign.id, + ) + return CampaignBonusResult(success=False) + + duration_days = campaign.subscription_duration_days or 0 + if duration_days <= 0: + logger.info( + "ℹ️ Кампания %s не содержит корректной длительности подписки", + campaign.id, + ) + return CampaignBonusResult(success=False) + + traffic_limit = campaign.subscription_traffic_gb + device_limit = ( + campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT + ) + squads = list(campaign.subscription_squads or []) + + if not squads and getattr(settings, "TRIAL_SQUAD_UUID", None): + squads = [settings.TRIAL_SQUAD_UUID] + + new_subscription = await create_paid_subscription( + db=db, + user_id=user.id, + duration_days=duration_days, + traffic_limit_gb=traffic_limit or 0, + device_limit=device_limit, + connected_squads=squads, + ) + + try: + await self.subscription_service.create_remnawave_user(db, new_subscription) + except Exception as error: + logger.error( + "❌ Ошибка синхронизации RemnaWave для кампании %s: %s", + campaign.id, + error, + ) + + await record_campaign_registration( + db, + campaign_id=campaign.id, + user_id=user.id, + bonus_type="subscription", + subscription_duration_days=duration_days, + ) + + logger.info( + "🎁 Пользователю %s выдана подписка по кампании %s на %s дней", + user.telegram_id, + campaign.id, + duration_days, + ) + + return CampaignBonusResult( + success=True, + bonus_type="subscription", + subscription_days=duration_days, + subscription_traffic_gb=traffic_limit or 0, + subscription_device_limit=device_limit, + subscription_squads=squads, + ) diff --git a/app/states.py b/app/states.py index b0479c08..35f461e0 100644 --- a/app/states.py +++ b/app/states.py @@ -41,6 +41,15 @@ class AdminStates(StatesGroup): setting_promocode_value = State() setting_promocode_uses = State() setting_promocode_expiry = State() + + creating_campaign_name = State() + creating_campaign_start = State() + creating_campaign_bonus = State() + creating_campaign_balance = State() + creating_campaign_subscription_days = State() + creating_campaign_subscription_traffic = State() + creating_campaign_subscription_devices = State() + creating_campaign_subscription_servers = State() waiting_for_broadcast_message = State() waiting_for_broadcast_media = State() diff --git a/migrations/alembic/versions/5d1f1f8b2e9a_add_advertising_campaigns.py b/migrations/alembic/versions/5d1f1f8b2e9a_add_advertising_campaigns.py new file mode 100644 index 00000000..31a943b8 --- /dev/null +++ b/migrations/alembic/versions/5d1f1f8b2e9a_add_advertising_campaigns.py @@ -0,0 +1,70 @@ +"""add advertising campaigns tables""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "5d1f1f8b2e9a" +down_revision: Union[str, None] = "cbd1be472f3d" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "advertising_campaigns", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("start_parameter", sa.String(length=64), nullable=False), + sa.Column("bonus_type", sa.String(length=20), nullable=False), + sa.Column("balance_bonus_kopeks", sa.Integer(), nullable=False, server_default="0"), + sa.Column("subscription_duration_days", sa.Integer(), nullable=True), + sa.Column("subscription_traffic_gb", sa.Integer(), nullable=True), + sa.Column("subscription_device_limit", sa.Integer(), nullable=True), + sa.Column("subscription_squads", sa.JSON(), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.Column("created_by", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="SET NULL"), + ) + op.create_index( + "ix_advertising_campaigns_start_parameter", + "advertising_campaigns", + ["start_parameter"], + unique=True, + ) + op.create_index( + "ix_advertising_campaigns_id", + "advertising_campaigns", + ["id"], + ) + + op.create_table( + "advertising_campaign_registrations", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("campaign_id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("bonus_type", sa.String(length=20), nullable=False), + sa.Column("balance_bonus_kopeks", sa.Integer(), nullable=False, server_default="0"), + sa.Column("subscription_duration_days", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["campaign_id"], ["advertising_campaigns.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.UniqueConstraint("campaign_id", "user_id", name="uq_campaign_user"), + ) + op.create_index( + "ix_advertising_campaign_registrations_id", + "advertising_campaign_registrations", + ["id"], + ) + + +def downgrade() -> None: + op.drop_index("ix_advertising_campaign_registrations_id", table_name="advertising_campaign_registrations") + op.drop_table("advertising_campaign_registrations") + op.drop_index("ix_advertising_campaigns_id", table_name="advertising_campaigns") + op.drop_index("ix_advertising_campaigns_start_parameter", table_name="advertising_campaigns") + op.drop_table("advertising_campaigns")