diff --git a/.env.example b/.env.example index 4e295c71..d129df68 100644 --- a/.env.example +++ b/.env.example @@ -194,6 +194,7 @@ CRYPTOBOT_INVOICE_EXPIRES_HOURS=24 # guide - открывает гайд подключения (режим 1) # miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2) # miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3) +# link - открывает ссылку подписки напрямую (режим 4) CONNECT_BUTTON_MODE=guide # URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom) diff --git a/README.md b/README.md index ebc0678a..a225d92b 100644 --- a/README.md +++ b/README.md @@ -362,6 +362,7 @@ PAYMENT_SUBSCRIPTION_TEMPLATE={service_name} - {description} # guide - открывает гайд подключения (режим 1) # miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2) # miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3) +# link - открывает ссылку подписки напрямую (режим 4) CONNECT_BUTTON_MODE=guide # URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom) diff --git a/app/database/crud/notification.py b/app/database/crud/notification.py new file mode 100644 index 00000000..d59b6190 --- /dev/null +++ b/app/database/crud/notification.py @@ -0,0 +1,52 @@ +import logging +from typing import Optional +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, delete + +from app.database.models import SentNotification + +logger = logging.getLogger(__name__) + + +async def notification_sent( + db: AsyncSession, + user_id: int, + subscription_id: int, + notification_type: str, + days_before: Optional[int] = None, +) -> bool: + result = await db.execute( + select(SentNotification).where( + SentNotification.user_id == user_id, + SentNotification.subscription_id == subscription_id, + SentNotification.notification_type == notification_type, + SentNotification.days_before == days_before, + ) + ) + return result.scalar_one_or_none() is not None + + +async def record_notification( + db: AsyncSession, + user_id: int, + subscription_id: int, + notification_type: str, + days_before: Optional[int] = None, +) -> None: + notification = SentNotification( + user_id=user_id, + subscription_id=subscription_id, + notification_type=notification_type, + days_before=days_before, + ) + db.add(notification) + await db.commit() + + +async def clear_notifications(db: AsyncSession, subscription_id: int) -> None: + await db.execute( + delete(SentNotification).where( + SentNotification.subscription_id == subscription_id + ) + ) + await db.commit() diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index dfda9e1c..f69aa7ad 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -6,9 +6,10 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.database.models import ( - Subscription, SubscriptionStatus, User, + Subscription, SubscriptionStatus, User, SubscriptionServer ) +from app.database.crud.notification import clear_notifications from app.utils.pricing_utils import calculate_months_from_days, get_remaining_months from app.config import settings @@ -121,10 +122,11 @@ async def extend_subscription( await db.commit() await db.refresh(subscription) - + await clear_notifications(db, subscription.id) + logger.info(f"✅ Подписка продлена до: {subscription.end_date}") logger.info(f"📊 Новые параметры: статус={subscription.status}, окончание={subscription.end_date}") - + return subscription diff --git a/app/database/models.py b/app/database/models.py index 8c392b10..70c9874a 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -555,6 +555,20 @@ class MonitoringLog(Base): created_at = Column(DateTime, default=func.now()) + +class SentNotification(Base): + __tablename__ = "sent_notifications" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + subscription_id = Column(Integer, ForeignKey("subscriptions.id", ondelete="CASCADE"), nullable=False) + notification_type = Column(String(50), nullable=False) + days_before = Column(Integer, nullable=True) + created_at = Column(DateTime, default=func.now()) + + user = relationship("User", backref="sent_notifications") + subscription = relationship("Subscription", backref="sent_notifications") + class BroadcastHistory(Base): __tablename__ = "broadcast_history" diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 0f1fa72e..dce7148b 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -416,6 +416,12 @@ async def activate_trial( [InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")], [InlineKeyboardButton(text="⬅️ В главное меню", callback_data="back_to_menu")], ]) + elif connect_mode == "link": + connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🔗 Подключиться", url=subscription.subscription_url)], + [InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")], + [InlineKeyboardButton(text="⬅️ В главное меню", callback_data="back_to_menu")], + ]) else: connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text="🔗 Подключиться", callback_data="subscription_connect")], @@ -1920,6 +1926,12 @@ async def confirm_purchase( [InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")], [InlineKeyboardButton(text="⬅️ В главное меню", callback_data="back_to_menu")], ]) + elif connect_mode == "link": + connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🔗 Подключиться", url=subscription.subscription_url)], + [InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")], + [InlineKeyboardButton(text="⬅️ В главное меню", callback_data="back_to_menu")], + ]) else: connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text="🔗 Подключиться", callback_data="subscription_connect")], @@ -2687,7 +2699,30 @@ async def handle_connect_subscription( reply_markup=keyboard, parse_mode="HTML" ) - + + elif connect_mode == "link": + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton( + text="🔗 Подключиться", + url=subscription.subscription_url + ) + ], + [ + InlineKeyboardButton(text="⬅️ Назад", callback_data="menu_subscription") + ] + ]) + + await callback.message.edit_text( + f""" +🚀 Подключить подписку + +🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки: + """, + reply_markup=keyboard, + parse_mode="HTML" + ) + else: device_text = f""" 📱 Подключить подписку diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 7ab3e675..7ea02102 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -163,6 +163,10 @@ def get_subscription_keyboard( keyboard.append([ InlineKeyboardButton(text="🔗 Подключиться", callback_data="subscription_connect") ]) + elif connect_mode == "link": + keyboard.append([ + InlineKeyboardButton(text="🔗 Подключиться", url=subscription.subscription_url) + ]) else: keyboard.append([ InlineKeyboardButton(text="🔗 Подключиться", callback_data="subscription_connect") diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index 80bcb6b8..39d2f689 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -17,6 +17,10 @@ from app.database.crud.user import ( get_user_by_id, get_inactive_users, delete_user, subtract_user_balance ) +from app.database.crud.notification import ( + notification_sent, + record_notification, +) from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User from app.services.subscription_service import SubscriptionService from app.services.payment_service import PaymentService @@ -186,31 +190,30 @@ class MonitoringService: user = await get_user_by_id(db, subscription.user_id) if not user: continue - - notification_key = f"expiring_{user.telegram_id}_{days}d_{subscription.id}" + user_key = f"user_{user.telegram_id}_today" - - if (notification_key in self._notified_users or + + if (await notification_sent(db, user.id, subscription.id, "expiring", days) or user_key in all_processed_users): logger.debug(f"🔄 Пропускаем дублирование для пользователя {user.telegram_id} на {days} дней") continue - + should_send = True for other_days in warning_days: - if other_days < days: + if other_days < days: other_subs = await self._get_expiring_paid_subscriptions(db, other_days) if any(s.user_id == user.id for s in other_subs): should_send = False logger.debug(f"🎯 Пропускаем уведомление на {days} дней для пользователя {user.telegram_id}, есть более срочное на {other_days} дней") break - + if not should_send: continue - + if self.bot: success = await self._send_subscription_expiring_notification(user, subscription, days) if success: - self._notified_users.add(notification_key) + await record_notification(db, user.id, subscription.id, "expiring", days) all_processed_users.add(user_key) sent_count += 1 logger.info(f"✅ Пользователю {user.telegram_id} отправлено уведомление об истечении подписки через {days} дней") @@ -249,15 +252,14 @@ class MonitoringService: user = subscription.user if not user: continue - - notification_key = f"trial_2h_{user.telegram_id}_{subscription.id}" - if notification_key in self._notified_users: - continue - + + if await notification_sent(db, user.id, subscription.id, "trial_2h"): + continue + if self.bot: success = await self._send_trial_ending_notification(user, subscription) if success: - self._notified_users.add(notification_key) + await record_notification(db, user.id, subscription.id, "trial_2h") logger.info(f"🎁 Пользователю {user.telegram_id} отправлено уведомление об окончании тестовой подписки через 2 часа") if trial_expiring: diff --git a/migrations/alembic/versions/8fd1e338eb45_add_sent_notifications_table.py b/migrations/alembic/versions/8fd1e338eb45_add_sent_notifications_table.py new file mode 100644 index 00000000..e7a0bff9 --- /dev/null +++ b/migrations/alembic/versions/8fd1e338eb45_add_sent_notifications_table.py @@ -0,0 +1,27 @@ +"""add sent notifications table""" + +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + +revision: str = '8fd1e338eb45' +down_revision: Union[str, None] = '3d9b35c6bd8f' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'sent_notifications', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False), + sa.Column('subscription_id', sa.Integer(), sa.ForeignKey('subscriptions.id'), nullable=False), + sa.Column('notification_type', sa.String(length=50), nullable=False), + sa.Column('days_before', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()), + sa.UniqueConstraint('user_id', 'subscription_id', 'notification_type', 'days_before', name='uq_sent_notifications'), + ) + + +def downgrade() -> None: + op.drop_table('sent_notifications') diff --git a/migrations/alembic/versions/cbd1be472f3d_add_cascade_to_sent_notifications.py b/migrations/alembic/versions/cbd1be472f3d_add_cascade_to_sent_notifications.py new file mode 100644 index 00000000..08ae5543 --- /dev/null +++ b/migrations/alembic/versions/cbd1be472f3d_add_cascade_to_sent_notifications.py @@ -0,0 +1,24 @@ +"""add cascade delete to sent notifications""" + +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + +revision: str = 'cbd1be472f3d' +down_revision: Union[str, None] = '8fd1e338eb45' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.drop_constraint('sent_notifications_user_id_fkey', 'sent_notifications', type_='foreignkey') + op.drop_constraint('sent_notifications_subscription_id_fkey', 'sent_notifications', type_='foreignkey') + op.create_foreign_key('fk_sent_notifications_user_id_users', 'sent_notifications', 'users', ['user_id'], ['id'], ondelete='CASCADE') + op.create_foreign_key('fk_sent_notifications_subscription_id_subscriptions', 'sent_notifications', 'subscriptions', ['subscription_id'], ['id'], ondelete='CASCADE') + + +def downgrade() -> None: + op.drop_constraint('fk_sent_notifications_user_id_users', 'sent_notifications', type_='foreignkey') + op.drop_constraint('fk_sent_notifications_subscription_id_subscriptions', 'sent_notifications', type_='foreignkey') + op.create_foreign_key('sent_notifications_user_id_fkey', 'sent_notifications', 'users', ['user_id'], ['id']) + op.create_foreign_key('sent_notifications_subscription_id_fkey', 'sent_notifications', 'subscriptions', ['subscription_id'], ['id'])