Refresh user state after campaign bonuses

This commit is contained in:
Egor
2025-09-19 12:29:28 +03:00
parent e62caa725f
commit 32da16f652
10 changed files with 1603 additions and 32 deletions

View File

@@ -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)

View File

@@ -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,
}

View File

@@ -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

View File

@@ -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"💰 Бонус на баланс: <b>{bonus_text}</b>"
else:
traffic_text = texts.format_traffic(campaign.subscription_traffic_gb or 0)
bonus_info = (
"📱 Подписка: <b>{days} д.</b>\n"
"🌐 Трафик: <b>{traffic}</b>\n"
"📱 Устройства: <b>{devices}</b>"
).format(
days=campaign.subscription_duration_days or 0,
traffic=traffic_text,
devices=campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT,
)
return (
f"<b>{campaign.name}</b>\n"
f"Стартовый параметр: <code>{campaign.start_parameter}</code>\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 = (
"📣 <b>Рекламные кампании</b>\n\n"
f"Всего кампаний: <b>{overview['total']}</b>\n"
f"Активных: <b>{overview['active']}</b> | Выключены: <b>{overview['inactive']}</b>\n"
f"Регистраций: <b>{overview['registrations']}</b>\n"
f"Выдано баланса: <b>{texts.format_price(overview['balance_total'])}</b>\n"
f"Выдано подписок: <b>{overview['subscription_total']}</b>"
)
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 = ["📊 <b>Общая статистика кампаний</b>\n"]
text.append(f"Всего кампаний: <b>{overview['total']}</b>")
text.append(
f"Активны: <b>{overview['active']}</b>, выключены: <b>{overview['inactive']}</b>"
)
text.append(f"Всего регистраций: <b>{overview['registrations']}</b>")
text.append(
f"Суммарно выдано баланса: <b>{texts.format_price(overview['balance_total'])}</b>"
)
text.append(f"Выдано подписок: <b>{overview['subscription_total']}</b>")
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 = ["📋 <b>Список кампаний</b>\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} <b>{campaign.name}</b> — <code>{campaign.start_parameter}</code>\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 = ["📣 <b>Управление кампанией</b>\n"]
text.append(_format_campaign_summary(campaign, texts))
text.append(f"🔗 Ссылка: <code>{deep_link}</code>")
text.append("\n📊 <b>Статистика</b>")
text.append(f"• Регистраций: <b>{stats['registrations']}</b>")
text.append(
f"• Выдано баланса: <b>{texts.format_price(stats['balance_issued'])}</b>"
)
text.append(f"• Выдано подписок: <b>{stats['subscription_issued']}</b>")
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 = ["📊 <b>Статистика кампании</b>\n"]
text.append(_format_campaign_summary(campaign, texts))
text.append(f"Регистраций: <b>{stats['registrations']}</b>")
text.append(f"Выдано баланса: <b>{texts.format_price(stats['balance_issued'])}</b>")
text.append(f"Выдано подписок: <b>{stats['subscription_issued']}</b>")
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 = (
"🗑️ <b>Удаление кампании</b>\n\n"
f"Название: <b>{campaign.name}</b>\n"
f"Параметр: <code>{campaign.start_parameter}</code>\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(
"🆕 <b>Создание рекламной кампании</b>\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 = (
"✅ <b>Кампания создана!</b>\n\n"
f"{summary}\n"
f"🔗 Ссылка: <code>{deep_link}</code>"
)
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 = (
"✅ <b>Кампания создана!</b>\n\n"
f"{summary}\n"
f"🔗 Ссылка: <code>{deep_link}</code>"
)
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,
)

View File

@@ -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)

View File

@@ -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=[
[

View File

@@ -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 = """
🎁 <b>Тестовая подписка скоро закончится!</b>
@@ -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 = {

View File

@@ -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,
)

View File

@@ -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()

View File

@@ -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")