Merge pull request #2773 from BEDOLAGA-DEV/dev

Dev
This commit is contained in:
Egor
2026-03-20 07:21:11 +03:00
committed by GitHub
18 changed files with 2190 additions and 119 deletions
+2
View File
@@ -20,6 +20,7 @@ from .admin_pinned_messages import router as admin_pinned_messages_router
from .admin_policies import router as admin_policies_router
from .admin_promo_offers import router as admin_promo_offers_router
from .admin_promocodes import promo_groups_router as admin_promo_groups_router, router as admin_promocodes_router
from .admin_referral_network import router as admin_referral_network_router
from .admin_remnawave import router as admin_remnawave_router
from .admin_roles import router as admin_roles_router
from .admin_sales_stats import router as admin_sales_stats_router
@@ -99,6 +100,7 @@ router.include_router(admin_wheel_router)
router.include_router(admin_tariffs_router)
router.include_router(admin_servers_router)
router.include_router(admin_stats_router)
router.include_router(admin_referral_network_router)
router.include_router(admin_sales_stats_router)
router.include_router(admin_ban_system_router)
router.include_router(admin_broadcasts_router)
File diff suppressed because it is too large Load Diff
+19 -1
View File
@@ -5,7 +5,7 @@ from datetime import UTC, datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, model_validator
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -90,6 +90,19 @@ class AdminReplyRequest(BaseModel):
"""Admin reply to ticket."""
message: str = Field(..., min_length=1, max_length=4000, description='Reply message')
media_type: str | None = Field(None, description='Media type: photo, video, or document')
media_file_id: str | None = Field(None, max_length=255, description='Telegram file_id from media upload')
media_caption: str | None = Field(None, max_length=1000, description='Caption for media')
@model_validator(mode='after')
def validate_media_fields(self) -> 'AdminReplyRequest':
if self.media_file_id and not self.media_type:
raise ValueError('media_type is required when media_file_id is provided')
if self.media_type and not self.media_file_id:
raise ValueError('media_file_id is required when media_type is provided')
if self.media_type and self.media_type not in {'photo', 'video', 'document'}:
raise ValueError('media_type must be one of: photo, video, document')
return self
class AdminStatusUpdateRequest(BaseModel):
@@ -443,11 +456,16 @@ async def reply_to_ticket(
)
# Create admin message
has_media = bool(request.media_file_id)
message = TicketMessage(
ticket_id=ticket.id,
user_id=ticket.user_id,
message_text=request.message,
is_from_admin=True,
has_media=has_media,
media_type=request.media_type if has_media else None,
media_file_id=request.media_file_id if has_media else None,
media_caption=request.media_caption if has_media else None,
created_at=datetime.now(UTC),
)
db.add(message)
+3 -3
View File
@@ -108,7 +108,7 @@ async def create_transaction(
await maybe_assign_promo_group_by_total_spent(db, user_id)
except Exception as exc:
logger.debug('Не удалось проверить автовыдачу промогруппы для пользователя', user_id=user_id, exc=exc)
logger.warning('Не удалось проверить автовыдачу промогруппы для пользователя', user_id=user_id, exc=exc)
if type == TransactionType.SUBSCRIPTION_PAYMENT and is_completed:
try:
from app.services.referral_contest_service import referral_contest_service
@@ -168,7 +168,7 @@ async def emit_transaction_side_effects(
await maybe_assign_promo_group_by_total_spent(db, user_id)
except Exception as exc:
logger.debug('Не удалось проверить автовыдачу промогруппы для пользователя', user_id=user_id, exc=exc)
logger.warning('Не удалось проверить автовыдачу промогруппы для пользователя', user_id=user_id, exc=exc)
if type == TransactionType.SUBSCRIPTION_PAYMENT and is_completed:
try:
@@ -253,7 +253,7 @@ async def complete_transaction(db: AsyncSession, transaction: Transaction) -> Tr
await maybe_assign_promo_group_by_total_spent(db, transaction.user_id)
except Exception as exc:
logger.debug(
logger.warning(
'Не удалось проверить автовыдачу промогруппы для пользователя', user_id=transaction.user_id, exc=exc
)
+56 -47
View File
@@ -3,7 +3,7 @@
from datetime import UTC, datetime
import structlog
from sqlalchemy import and_, desc, select
from sqlalchemy import and_, desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -24,7 +24,7 @@ async def _sync_user_primary_promo_group(
select(UserPromoGroup.promo_group_id)
.join(PromoGroup, UserPromoGroup.promo_group_id == PromoGroup.id)
.where(UserPromoGroup.user_id == user_id)
.order_by(desc(PromoGroup.priority), PromoGroup.id)
.order_by(desc(PromoGroup.priority), desc(PromoGroup.id))
)
first = result.first()
@@ -53,7 +53,12 @@ async def sync_user_primary_promo_group(
async def add_user_to_promo_group(
db: AsyncSession, user_id: int, promo_group_id: int, assigned_by: str = 'admin'
db: AsyncSession,
user_id: int,
promo_group_id: int,
assigned_by: str = 'admin',
*,
commit: bool = True,
) -> UserPromoGroup | None:
"""
Добавляет пользователю промогруппу.
@@ -63,6 +68,7 @@ async def add_user_to_promo_group(
user_id: ID пользователя
promo_group_id: ID промогруппы
assigned_by: Кто назначил ('admin', 'system', 'auto', 'promocode')
commit: Коммитить транзакцию (False для батчевых операций)
Returns:
UserPromoGroup или None если уже существует
@@ -85,8 +91,9 @@ async def add_user_to_promo_group(
await _sync_user_primary_promo_group(db, user_id)
await db.commit()
await db.refresh(user_promo_group)
if commit:
await db.commit()
await db.refresh(user_promo_group)
logger.info(
'Пользователю добавлена промогруппа',
@@ -98,11 +105,19 @@ async def add_user_to_promo_group(
except Exception as error:
logger.error('Ошибка добавления промогруппы пользователю', error=error)
await db.rollback()
return None
if commit:
await db.rollback()
return None
raise
async def remove_user_from_promo_group(db: AsyncSession, user_id: int, promo_group_id: int) -> bool:
async def remove_user_from_promo_group(
db: AsyncSession,
user_id: int,
promo_group_id: int,
*,
commit: bool = True,
) -> bool:
"""
Удаляет промогруппу у пользователя.
@@ -110,6 +125,7 @@ async def remove_user_from_promo_group(db: AsyncSession, user_id: int, promo_gro
db: Сессия БД
user_id: ID пользователя
promo_group_id: ID промогруппы
commit: Коммитить транзакцию (False для батчевых операций)
Returns:
True если удалено, False если связи не было
@@ -133,15 +149,18 @@ async def remove_user_from_promo_group(db: AsyncSession, user_id: int, promo_gro
await _sync_user_primary_promo_group(db, user_id)
await db.commit()
if commit:
await db.commit()
logger.info('У пользователя удалена промогруппа', user_id=user_id, promo_group_id=promo_group_id)
return True
except Exception as error:
logger.error('Ошибка удаления промогруппы у пользователя', error=error)
await db.rollback()
return False
if commit:
await db.rollback()
return False
raise
async def get_user_promo_groups(db: AsyncSession, user_id: int) -> list[UserPromoGroup]:
@@ -155,19 +174,14 @@ async def get_user_promo_groups(db: AsyncSession, user_id: int) -> list[UserProm
Returns:
Список UserPromoGroup с загруженными PromoGroup, отсортированный по приоритету DESC
"""
try:
result = await db.execute(
select(UserPromoGroup)
.options(selectinload(UserPromoGroup.promo_group))
.where(UserPromoGroup.user_id == user_id)
.join(PromoGroup, UserPromoGroup.promo_group_id == PromoGroup.id)
.order_by(desc(PromoGroup.priority), PromoGroup.id)
)
return list(result.scalars().all())
except Exception as error:
logger.error('Ошибка получения промогрупп пользователя', user_id=user_id, error=error)
return []
result = await db.execute(
select(UserPromoGroup)
.options(selectinload(UserPromoGroup.promo_group))
.where(UserPromoGroup.user_id == user_id)
.join(PromoGroup, UserPromoGroup.promo_group_id == PromoGroup.id)
.order_by(desc(PromoGroup.priority), desc(PromoGroup.id))
)
return list(result.scalars().all())
async def get_primary_user_promo_group(db: AsyncSession, user_id: int) -> PromoGroup | None:
@@ -181,19 +195,14 @@ async def get_primary_user_promo_group(db: AsyncSession, user_id: int) -> PromoG
Returns:
PromoGroup с максимальным приоритетом или None
"""
try:
user_promo_groups = await get_user_promo_groups(db, user_id)
user_promo_groups = await get_user_promo_groups(db, user_id)
if not user_promo_groups:
return None
# Первая в списке имеет максимальный приоритет (список уже отсортирован)
return user_promo_groups[0].promo_group or None
except Exception as error:
logger.error('Ошибка получения primary промогруппы пользователя', user_id=user_id, error=error)
if not user_promo_groups:
return None
# Первая в списке имеет максимальный приоритет (список уже отсортирован)
return user_promo_groups[0].promo_group or None
async def has_user_promo_group(db: AsyncSession, user_id: int, promo_group_id: int) -> bool:
"""
@@ -207,17 +216,12 @@ async def has_user_promo_group(db: AsyncSession, user_id: int, promo_group_id: i
Returns:
True если пользователь уже имеет эту промогруппу
"""
try:
result = await db.execute(
select(UserPromoGroup).where(
and_(UserPromoGroup.user_id == user_id, UserPromoGroup.promo_group_id == promo_group_id)
)
result = await db.execute(
select(UserPromoGroup).where(
and_(UserPromoGroup.user_id == user_id, UserPromoGroup.promo_group_id == promo_group_id)
)
return result.scalar_one_or_none() is not None
except Exception as error:
logger.error('Ошибка проверки промогруппы пользователя', error=error)
return False
)
return result.scalar_one_or_none() is not None
async def count_user_promo_groups(db: AsyncSession, user_id: int) -> int:
@@ -232,8 +236,10 @@ async def count_user_promo_groups(db: AsyncSession, user_id: int) -> int:
Количество промогрупп
"""
try:
result = await db.execute(select(UserPromoGroup).where(UserPromoGroup.user_id == user_id))
return len(list(result.scalars().all()))
result = await db.execute(
select(func.count()).select_from(UserPromoGroup).where(UserPromoGroup.user_id == user_id)
)
return result.scalar_one()
except Exception as error:
logger.error('Ошибка подсчета промогрупп пользователя', error=error)
@@ -257,15 +263,18 @@ async def replace_user_promo_groups(
"""
try:
# Удаляем все текущие промогруппы
await db.execute(select(UserPromoGroup).where(UserPromoGroup.user_id == user_id))
result = await db.execute(select(UserPromoGroup).where(UserPromoGroup.user_id == user_id))
for upg in result.scalars().all():
await db.delete(upg)
await db.flush()
# Добавляем новые
for promo_group_id in promo_group_ids:
user_promo_group = UserPromoGroup(user_id=user_id, promo_group_id=promo_group_id, assigned_by=assigned_by)
db.add(user_promo_group)
await db.flush()
await _sync_user_primary_promo_group(db, user_id)
await db.commit()
logger.info('Промогруппы пользователя заменены на', user_id=user_id, promo_group_ids=promo_group_ids)
+6 -1
View File
@@ -1574,6 +1574,7 @@ class Transaction(Base):
Index('ix_transactions_type_created_completed', 'type', 'created_at', 'is_completed'),
Index('ix_transactions_user_created', 'user_id', 'created_at'),
Index('ix_transactions_type_method_created', 'type', 'payment_method', 'created_at'),
Index('ix_transactions_user_type_completed_amount', 'user_id', 'type', 'is_completed', 'amount_kopeks'),
)
id = Column(Integer, primary_key=True, index=True)
@@ -2490,7 +2491,10 @@ class AdvertisingCampaign(Base):
class AdvertisingCampaignRegistration(Base):
__tablename__ = 'advertising_campaign_registrations'
__table_args__ = (UniqueConstraint('campaign_id', 'user_id', name='uq_campaign_user'),)
__table_args__ = (
UniqueConstraint('campaign_id', 'user_id', name='uq_campaign_user'),
Index('ix_campaign_reg_user_created', 'user_id', 'created_at'),
)
id = Column(Integer, primary_key=True, index=True)
campaign_id = Column(Integer, ForeignKey('advertising_campaigns.id', ondelete='CASCADE'), nullable=False)
@@ -3283,6 +3287,7 @@ class GuestPurchase(Base):
cabinet_password = Column(Text, nullable=True)
auto_login_token = Column(Text, nullable=True)
recipient_warning = Column(String(50), nullable=True)
retry_count = Column(Integer, nullable=False, default=0, server_default='0')
landing = relationship('LandingPage', back_populates='guest_purchases', lazy='selectin')
tariff = relationship('Tariff', lazy='selectin')
-4
View File
@@ -354,10 +354,6 @@ async def _handle_guest_purchase_payment(
stars_amount=stars_amount,
purchase_token_prefix=purchase_token[:5],
)
elif result is False:
await message.answer(
'❌ Произошла ошибка при обработке подарочной подписки. Обратитесь в поддержку.',
)
else:
logger.error('try_fulfill_guest_purchase returned None for Stars gift', payload=payload)
await message.answer('❌ Ошибка обработки платежа. Обратитесь в поддержку.')
+415 -16
View File
@@ -7,7 +7,7 @@ from datetime import UTC, datetime, timedelta
from typing import Literal
import structlog
from sqlalchemy import func, or_, select
from sqlalchemy import func, or_, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
@@ -392,24 +392,41 @@ async def fulfill_purchase(
return purchase
def _resolve_base_payment_method(method_str: str | None) -> str:
"""Resolve base payment method string by stripping sub-option suffixes.
'yookassa_sbp' 'yookassa', 'kassa_ai' 'kassa_ai' (enum match keeps it),
'platega_2' 'platega'.
"""
if not method_str:
return ''
# If exact enum match, return as-is (handles 'telegram_stars', 'kassa_ai', etc.)
try:
PaymentMethod(method_str)
return method_str
except ValueError:
pass
# Strip sub-option suffix
if '_' in method_str:
base = method_str.rsplit('_', 1)[0]
try:
PaymentMethod(base)
return base
except ValueError:
pass
return method_str
def _resolve_payment_method(method_str: str | None) -> PaymentMethod | None:
"""Convert payment method string from GuestPurchase to PaymentMethod enum."""
if not method_str:
return None
# Try exact match first (handles 'telegram_stars', 'kassa_ai', 'yookassa', etc.)
base = _resolve_base_payment_method(method_str)
try:
return PaymentMethod(method_str)
return PaymentMethod(base)
except ValueError:
pass
# Strip sub-option suffix ('yookassa_sbp' → 'yookassa', 'platega_2' → 'platega')
if '_' in method_str:
base_method = method_str.split('_')[0]
try:
return PaymentMethod(base_method)
except ValueError:
pass
logger.debug('Unknown payment method for transaction', method=method_str)
return None
logger.debug('Unknown payment method for transaction', method=method_str)
return None
def _mask_email(email: str) -> str:
@@ -1002,14 +1019,16 @@ async def retry_stuck_paid_purchases(
stale_minutes: int = 5,
limit: int = 10,
max_age_hours: int = 24,
max_retries: int = 20,
) -> int:
"""Retry fulfillment for purchases stuck in PAID status.
Finds purchases that have been in PAID status for longer than stale_minutes
(but not older than max_age_hours) and attempts to fulfill them in isolated
sessions. Returns the number of successfully retried purchases.
(but not older than max_age_hours, and with retry_count < max_retries) and
attempts to fulfill them in isolated sessions.
Purchases older than max_age_hours are left for manual investigation.
Purchases exceeding max_retries are marked FAILED and an admin alert is sent.
Returns the number of successfully retried purchases.
"""
from app.database.database import AsyncSessionLocal
@@ -1018,10 +1037,12 @@ async def retry_stuck_paid_purchases(
# Collect tokens only — each retry gets its own session.
# NULL paid_at is included via or_() as a safety net for data anomalies.
# Filter retry_count < max_retries in SQL to avoid wasting LIMIT slots.
result = await db.execute(
select(GuestPurchase.token)
.where(
GuestPurchase.status == GuestPurchaseStatus.PAID.value,
GuestPurchase.retry_count < max_retries,
or_(GuestPurchase.paid_at < cutoff, GuestPurchase.paid_at.is_(None)),
or_(GuestPurchase.paid_at > max_age, GuestPurchase.paid_at.is_(None)),
# Exclude code-only gifts — they stay PAID intentionally until activated
@@ -1032,6 +1053,9 @@ async def retry_stuck_paid_purchases(
)
tokens = result.scalars().all()
# Separately fail exhausted purchases (retry_count >= max_retries)
await _fail_exhausted_purchases_batch(db, GuestPurchaseStatus.PAID, max_retries, max_age)
if not tokens:
return 0
@@ -1039,6 +1063,7 @@ async def retry_stuck_paid_purchases(
for token in tokens:
try:
async with AsyncSessionLocal() as retry_db:
await _increment_retry_count(retry_db, token)
await fulfill_purchase(retry_db, token)
retried += 1
logger.info('Retried stuck purchase successfully', token_prefix=token[:5])
@@ -1053,12 +1078,15 @@ async def retry_stuck_pending_activation(
stale_minutes: int = 10,
limit: int = 10,
max_age_hours: int = 24,
max_retries: int = 20,
) -> int:
"""Retry activation for purchases stuck in PENDING_ACTIVATION status.
This handles the case where activate_purchase() failed after the status
was already transitioned to PENDING_ACTIVATION (e.g., Remnawave panel was
temporarily down). Each retry runs in an isolated session.
Purchases exceeding max_retries are marked FAILED and an admin alert is sent.
"""
from app.database.database import AsyncSessionLocal
@@ -1069,6 +1097,7 @@ async def retry_stuck_pending_activation(
select(GuestPurchase.token)
.where(
GuestPurchase.status == GuestPurchaseStatus.PENDING_ACTIVATION.value,
GuestPurchase.retry_count < max_retries,
or_(GuestPurchase.paid_at < cutoff, GuestPurchase.paid_at.is_(None)),
or_(GuestPurchase.paid_at > max_age, GuestPurchase.paid_at.is_(None)),
GuestPurchase.user_id.isnot(None),
@@ -1078,6 +1107,9 @@ async def retry_stuck_pending_activation(
)
tokens = result.scalars().all()
# Separately fail exhausted purchases (retry_count >= max_retries)
await _fail_exhausted_purchases_batch(db, GuestPurchaseStatus.PENDING_ACTIVATION, max_retries, max_age)
if not tokens:
return 0
@@ -1085,6 +1117,7 @@ async def retry_stuck_pending_activation(
for token in tokens:
try:
async with AsyncSessionLocal() as retry_db:
await _increment_retry_count(retry_db, token)
await activate_purchase(retry_db, token)
retried += 1
logger.info('Retried stuck pending_activation successfully', token_prefix=token[:5])
@@ -1092,3 +1125,369 @@ async def retry_stuck_pending_activation(
logger.exception('Failed to retry stuck pending_activation', token_prefix=token[:5])
return retried
async def _increment_retry_count(db: AsyncSession, purchase_token: str) -> None:
"""Atomically increment retry_count via UPDATE statement (no SELECT, no identity map pollution)."""
await db.execute(
update(GuestPurchase)
.where(GuestPurchase.token == purchase_token)
.values(retry_count=GuestPurchase.retry_count + 1)
)
await db.commit()
async def _fail_exhausted_purchases_batch(
db: AsyncSession,
status: GuestPurchaseStatus,
max_retries: int,
max_age: datetime,
) -> None:
"""Find and mark exhausted purchases as FAILED, then send admin alerts."""
from app.database.crud.landing import update_purchase_status
from app.database.database import AsyncSessionLocal
result = await db.execute(
select(GuestPurchase.token, GuestPurchase.retry_count)
.where(
GuestPurchase.status == status.value,
GuestPurchase.retry_count >= max_retries,
or_(GuestPurchase.paid_at > max_age, GuestPurchase.paid_at.is_(None)),
)
.limit(10)
)
exhausted = result.all()
for token, retry_count in exhausted:
# Collect alert data before closing the session
alert_data: dict | None = None
try:
async with AsyncSessionLocal() as fail_db:
row = await fail_db.execute(select(GuestPurchase).where(GuestPurchase.token == token).with_for_update())
purchase = row.scalars().first()
if purchase and purchase.status not in (
GuestPurchaseStatus.DELIVERED.value,
GuestPurchaseStatus.FAILED.value,
):
# Capture alert data before commit expires attributes
alert_data = {
'id': purchase.id,
'token': purchase.token,
'amount_kopeks': purchase.amount_kopeks,
'payment_method': purchase.payment_method,
'payment_id': purchase.payment_id,
'contact_type': purchase.contact_type,
'contact_value': purchase.contact_value,
'created_at': purchase.created_at,
}
await update_purchase_status(fail_db, token, GuestPurchaseStatus.FAILED)
logger.error(
'Purchase exceeded max retries — marked FAILED',
token_prefix=token[:5],
retry_count=retry_count,
phase=status.value,
)
except Exception:
logger.exception('Failed to mark exhausted purchase as FAILED', token_prefix=token[:5])
# Send alert OUTSIDE the session (no row lock held)
if alert_data:
await _send_stuck_purchase_alert(alert_data, retry_count, status.value)
async def _send_stuck_purchase_alert(data: dict, retry_count: int, phase: str) -> None:
"""Send admin notification about a purchase that exhausted all retries.
Accepts a plain dict (not ORM object) so it can be called after the session is closed.
"""
if not getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) or not settings.BOT_TOKEN:
return
try:
import html as html_mod
from aiogram import Bot
from app.services.admin_notification_service import AdminNotificationService, NotificationCategory
amount_rub = data['amount_kopeks'] / 100
contact_value = html_mod.escape(str(data.get('contact_value', '?')))
contact_type = html_mod.escape(str(data.get('contact_type', '?')))
text = (
f'<b>STUCK PURCHASE — retries exhausted</b>\n\n'
f'Token: <code>{data["token"][:8]}...</code>\n'
f'Status: <code>{phase}</code> → <code>FAILED</code>\n'
f'Retries: <b>{retry_count}</b>\n'
f'Amount: <b>{amount_rub:.0f} ₽</b>\n'
f'Payment: <code>{html_mod.escape(str(data.get("payment_method") or "?"))}</code>\n'
f'Payment ID: <code>{html_mod.escape(str(data.get("payment_id") or "?"))}</code>\n'
f'Contact: {contact_type}: <code>{contact_value}</code>\n'
f'Created: {data["created_at"]:%Y-%m-%d %H:%M UTC}\n\n'
f'Requires manual investigation.'
)
async with Bot(token=settings.BOT_TOKEN) as bot:
service = AdminNotificationService(bot)
await service.send_admin_notification(text, category=NotificationCategory.ERRORS)
except Exception:
logger.warning('Failed to send stuck purchase admin alert', purchase_id=data.get('id'), exc_info=True)
async def _send_amount_mismatch_alert(
purchase: GuestPurchase,
provider_amount_kopeks: int,
provider_payment_id: str,
payment_method: str | None,
) -> None:
"""Send admin alert when recovery detects an amount mismatch (possible fraud or bug)."""
if not getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) or not settings.BOT_TOKEN:
return
try:
import html as html_mod
from aiogram import Bot
from app.services.admin_notification_service import AdminNotificationService, NotificationCategory
text = (
f'<b>AMOUNT MISMATCH — purchase marked FAILED</b>\n\n'
f'Token: <code>{purchase.token[:8]}...</code>\n'
f'Expected: <b>{purchase.amount_kopeks / 100:.0f} ₽</b>\n'
f'Provider: <b>{provider_amount_kopeks / 100:.0f} ₽</b>\n'
f'Payment: <code>{html_mod.escape(str(payment_method or "?"))}</code>\n'
f'Payment ID: <code>{html_mod.escape(str(provider_payment_id))}</code>\n'
f'Contact: {html_mod.escape(str(purchase.contact_type))}: '
f'<code>{html_mod.escape(str(purchase.contact_value))}</code>\n\n'
f'Requires manual investigation.'
)
async with Bot(token=settings.BOT_TOKEN) as bot:
service = AdminNotificationService(bot)
await service.send_admin_notification(text, category=NotificationCategory.ERRORS)
except Exception:
logger.warning('Failed to send amount mismatch alert', purchase_id=purchase.id, exc_info=True)
async def recover_stuck_pending_purchases(
db: AsyncSession,
stale_minutes: int = 10,
limit: int = 10,
max_age_hours: int = 24,
) -> int:
"""Recover purchases stuck in PENDING by checking provider payment status.
Queries all payment provider tables (YooKassa, Heleket, CryptoBot, etc.)
for succeeded payments matching the purchase_token. If a provider payment
is confirmed but the GuestPurchase is still PENDING (webhook was lost or
processing failed), marks the purchase as PAID so retry_stuck_paid_purchases
can fulfill it. Includes amount verification.
Returns the number of recovered purchases.
"""
from app.database.database import AsyncSessionLocal
cutoff = datetime.now(UTC) - timedelta(minutes=stale_minutes)
max_age = datetime.now(UTC) - timedelta(hours=max_age_hours)
# Find PENDING purchases older than stale_minutes but younger than max_age_hours
result = await db.execute(
select(GuestPurchase.token, GuestPurchase.payment_method)
.where(
GuestPurchase.status == GuestPurchaseStatus.PENDING.value,
GuestPurchase.created_at < cutoff,
GuestPurchase.created_at > max_age,
)
.order_by(GuestPurchase.created_at.asc())
.limit(limit)
)
pending_purchases = result.all()
if not pending_purchases:
return 0
recovered = 0
for token, payment_method in pending_purchases:
try:
async with AsyncSessionLocal() as recover_db:
paid = await _check_and_recover_pending_purchase(recover_db, token, payment_method)
if paid:
recovered += 1
except Exception:
logger.exception('Failed to recover pending purchase', token_prefix=token[:5])
return recovered
async def _find_succeeded_provider_payment(
db: AsyncSession,
base_method: str,
purchase_token: str,
) -> tuple[str, int | None] | None:
"""Query provider payment tables for a succeeded payment matching purchase_token.
Returns ``(provider_payment_id, amount_kopeks)`` or ``None``.
``amount_kopeks`` is ``None`` when the amount check should be skipped
(e.g., CryptoBot where USDRUB conversion introduces imprecision).
"""
from sqlalchemy import cast
from sqlalchemy.types import JSON as SA_JSON
from app.database.models import (
CloudPaymentsPayment,
CryptoBotPayment,
FreekassaPayment,
HeleketPayment,
KassaAiPayment,
MulenPayPayment,
Pal24Payment,
PlategaPayment,
RioPayPayment,
SeverPayPayment,
WataPayment,
YooKassaPayment,
)
# --- CryptoBot: special case — payload field (text JSON), skip amount check ---
if base_method == 'cryptobot':
result = await db.execute(
select(CryptoBotPayment).where(
CryptoBotPayment.status == 'paid',
cast(CryptoBotPayment.payload, SA_JSON)['purchase_token'].as_string() == purchase_token,
)
)
p = result.scalars().first()
return (p.invoice_id, None) if p else None
# --- All other providers: metadata_json['purchase_token'] + is_paid/status filters ---
model = None
payment_id_attr: str = ''
extra_conditions: list = []
if base_method.startswith('yookassa'):
model = YooKassaPayment
payment_id_attr = 'yookassa_payment_id'
extra_conditions = [YooKassaPayment.status == 'succeeded', YooKassaPayment.is_paid.is_(True)]
elif base_method == 'heleket':
model = HeleketPayment
payment_id_attr = 'uuid'
extra_conditions = [HeleketPayment.status.in_(['paid', 'paid_over'])]
elif base_method == 'mulenpay':
model = MulenPayPayment
payment_id_attr = 'uuid'
extra_conditions = [MulenPayPayment.is_paid.is_(True)]
elif base_method == 'pal24':
model = Pal24Payment
payment_id_attr = 'bill_id'
extra_conditions = [Pal24Payment.is_paid.is_(True)]
elif base_method == 'wata':
model = WataPayment
payment_id_attr = 'payment_link_id'
extra_conditions = [WataPayment.is_paid.is_(True)]
elif base_method == 'platega':
model = PlategaPayment
payment_id_attr = 'platega_transaction_id'
extra_conditions = [PlategaPayment.is_paid.is_(True)]
elif base_method == 'cloudpayments':
model = CloudPaymentsPayment
payment_id_attr = 'invoice_id'
extra_conditions = [CloudPaymentsPayment.status == 'completed', CloudPaymentsPayment.is_paid.is_(True)]
elif base_method == 'freekassa':
model = FreekassaPayment
payment_id_attr = 'order_id'
extra_conditions = [FreekassaPayment.status == 'success', FreekassaPayment.is_paid.is_(True)]
elif base_method == 'kassa_ai':
model = KassaAiPayment
payment_id_attr = 'order_id'
extra_conditions = [KassaAiPayment.status == 'success', KassaAiPayment.is_paid.is_(True)]
elif base_method == 'riopay':
model = RioPayPayment
payment_id_attr = 'order_id'
extra_conditions = [RioPayPayment.status == 'success', RioPayPayment.is_paid.is_(True)]
elif base_method == 'severpay':
model = SeverPayPayment
payment_id_attr = 'order_id'
extra_conditions = [SeverPayPayment.status == 'success', SeverPayPayment.is_paid.is_(True)]
if model is None:
return None
result = await db.execute(
select(model).where(
model.metadata_json['purchase_token'].as_string() == purchase_token,
*extra_conditions,
)
)
p = result.scalars().first()
if p is None:
return None
payment_id = str(getattr(p, payment_id_attr))
# amount_kopeks: Integer column for most providers, @property for Heleket
amount = getattr(p, 'amount_kopeks', None)
return (payment_id, amount)
async def _check_and_recover_pending_purchase(
db: AsyncSession,
purchase_token: str,
payment_method: str | None,
) -> bool:
"""Check if a PENDING purchase has a succeeded payment and transition to PAID.
Uses SELECT ... FOR UPDATE on the GuestPurchase row to prevent concurrent
webhook processing from racing with the recovery.
Verifies amount match between provider payment and guest purchase.
"""
from app.database.crud.landing import update_purchase_status
# Lock the row to prevent TOCTOU race with concurrent webhook processing
result = await db.execute(select(GuestPurchase).where(GuestPurchase.token == purchase_token).with_for_update())
purchase = result.scalars().first()
if purchase is None or purchase.status != GuestPurchaseStatus.PENDING.value:
return False
# Resolve base method: 'yookassa_sbp' → 'yookassa', 'kassa_ai' stays 'kassa_ai'
base_method = _resolve_base_payment_method(payment_method)
match = await _find_succeeded_provider_payment(db, base_method, purchase_token)
if match is None:
if base_method:
logger.debug(
'No succeeded provider payment found for PENDING purchase',
token_prefix=purchase_token[:5],
payment_method=payment_method,
)
return False
provider_payment_id, provider_amount_kopeks = match
# Amount verification (skip when provider_amount_kopeks is None, e.g., crypto)
if provider_amount_kopeks is not None and provider_amount_kopeks != purchase.amount_kopeks:
logger.error(
'Amount mismatch during PENDING recovery — skipping',
token_prefix=purchase_token[:5],
provider_amount=provider_amount_kopeks,
purchase_amount=purchase.amount_kopeks,
payment_method=payment_method,
)
# Mark FAILED to prevent repeated mismatch logs every cycle
from app.database.crud.landing import update_purchase_status as _update_status
await _update_status(db, purchase_token, GuestPurchaseStatus.FAILED)
await _send_amount_mismatch_alert(purchase, provider_amount_kopeks, provider_payment_id, payment_method)
return False
# Transition PENDING → PAID for retry_stuck_paid_purchases to handle
await update_purchase_status(
db,
purchase_token,
GuestPurchaseStatus.PAID,
payment_id=provider_payment_id,
paid_at=datetime.now(UTC),
)
logger.info(
'Recovered stuck PENDING purchase → PAID',
token_prefix=purchase_token[:5],
payment_method=payment_method,
provider_payment_id=provider_payment_id,
)
return True
+20 -3
View File
@@ -1742,18 +1742,35 @@ class MonitoringService:
)
async def _retry_stuck_guest_purchases(self, db: AsyncSession):
try:
from app.services.guest_purchase_service import retry_stuck_paid_purchases, retry_stuck_pending_activation
from app.services.guest_purchase_service import (
recover_stuck_pending_purchases,
retry_stuck_paid_purchases,
retry_stuck_pending_activation,
)
# Phase 1: Recover PENDING purchases where provider payment already succeeded
try:
recovered = await recover_stuck_pending_purchases(db, stale_minutes=10, limit=10)
if recovered:
logger.info('Recovered stuck PENDING purchases', recovered=recovered)
except Exception:
logger.error('Error recovering stuck PENDING guest purchases', exc_info=True)
# Phase 2: Retry fulfillment for purchases in PAID status
try:
retried = await retry_stuck_paid_purchases(db, stale_minutes=5, limit=10)
if retried:
logger.info('Retried stuck guest purchases', retried=retried)
except Exception:
logger.error('Error retrying stuck PAID guest purchases', exc_info=True)
# Phase 3: Retry activation for purchases in PENDING_ACTIVATION status
try:
retried_pa = await retry_stuck_pending_activation(db, stale_minutes=10, limit=10)
if retried_pa:
logger.info('Retried stuck pending_activation purchases', retried=retried_pa)
except Exception:
logger.error('Error retrying stuck guest purchases', exc_info=True)
logger.error('Error retrying stuck PENDING_ACTIVATION guest purchases', exc_info=True)
async def _cleanup_inactive_users(self, db: AsyncSession):
try:
+23 -11
View File
@@ -476,8 +476,7 @@ async def try_fulfill_guest_purchase(
introduces imprecision.
Returns:
``True`` -- guest purchase was detected and successfully fulfilled.
``False`` -- guest purchase was detected but fulfillment failed.
``True`` -- guest purchase was detected and consumed (fulfilled or queued for retry).
``None`` -- this is NOT a guest purchase (caller should proceed normally).
"""
purchase_token = _extract_guest_purchase_token(metadata)
@@ -485,7 +484,7 @@ async def try_fulfill_guest_purchase(
return None
from app.database.crud.landing import get_purchase_by_token, update_purchase_status
from app.database.models import GuestPurchaseStatus
from app.database.models import GuestPurchase, GuestPurchaseStatus
from app.services.guest_purchase_service import fulfill_purchase
try:
@@ -558,13 +557,26 @@ async def try_fulfill_guest_purchase(
provider=provider_name,
error=guest_error,
)
# Mark as FAILED so it doesn't get retried forever
# Mark as PAID (not FAILED) so retry_stuck_paid_purchases can pick it up.
# Use a fresh session to avoid tainted-session issues after rollback.
# The monitoring service retries PAID purchases every 5 minutes for up to 24 hours.
try:
await update_purchase_status(
db,
purchase_token,
GuestPurchaseStatus.FAILED,
)
from app.database.database import AsyncSessionLocal
async with AsyncSessionLocal() as recovery_db:
# Use FOR UPDATE to prevent TOCTOU race with concurrent webhook.
row = await recovery_db.execute(
select(GuestPurchase).where(GuestPurchase.token == purchase_token).with_for_update()
)
current = row.scalars().first()
if current and current.status in (
GuestPurchaseStatus.PENDING.value,
GuestPurchaseStatus.PAID.value,
):
current.status = GuestPurchaseStatus.PAID.value
current.payment_id = provider_payment_id
current.paid_at = datetime.now(UTC)
await recovery_db.commit()
except Exception:
logger.exception('Failed to mark guest purchase as FAILED')
return False
logger.exception('Failed to mark guest purchase as PAID for retry')
return True
+1 -1
View File
@@ -721,7 +721,7 @@ class PaymentService(
payment_system_id=ps_id,
)
if result:
await _patch_guest_metadata(result['local_payment_id'], payment_method)
await _patch_guest_metadata(result['local_payment_id'], 'kassa_ai')
return {
'payment_url': result.get('payment_url'),
'payment_id': result.get('order_id'),
+40 -26
View File
@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.transaction import get_user_total_spent_kopeks
from app.database.crud.user import lock_user_for_update
from app.database.models import PromoGroup, User
from app.services.admin_notification_service import AdminNotificationService
@@ -90,7 +91,9 @@ async def maybe_assign_promo_group_by_total_spent(
) -> PromoGroup | None:
from app.database.crud.user_promo_group import (
add_user_to_promo_group,
get_user_promo_groups,
has_user_promo_group,
remove_user_from_promo_group,
sync_user_primary_promo_group,
)
@@ -99,6 +102,9 @@ async def maybe_assign_promo_group_by_total_spent(
logger.debug('Не удалось найти пользователя для автовыдачи промогруппы', user_id=user_id)
return None
# Блокируем строку пользователя для предотвращения гонок при конкурентных вебхуках
user = await lock_user_for_update(db, user)
# Получаем текущую primary промогруппу
old_group = user.get_primary_promo_group()
@@ -108,60 +114,68 @@ async def maybe_assign_promo_group_by_total_spent(
previous_threshold = user.auto_promo_group_threshold_kopeks or 0
target_group = await _get_best_group_for_spending(
db,
total_spent,
min_threshold_kopeks=previous_threshold,
)
# Находим группу, соответствующую текущим тратам (без порогового фильтра,
# чтобы промокод-группы всегда очищались при покупке)
target_group = await _get_best_group_for_spending(db, total_spent)
if not target_group:
return None
try:
target_threshold = target_group.auto_assign_total_spent_kopeks or 0
if target_threshold <= previous_threshold:
logger.debug(
"Порог промогруппы '' не превышает ранее назначенный для пользователя",
target_group_name=target_group.name,
target_threshold=target_threshold,
previous_threshold=previous_threshold,
telegram_id=user.telegram_id,
)
return None
# Фаза 1: Удаляем старые auto/promocode группы, отличные от целевой
current_groups = await get_user_promo_groups(db, user_id)
removed_any = False
for upg in current_groups:
if upg.promo_group_id != target_group.id and upg.assigned_by in ('auto', 'promocode'):
await remove_user_from_promo_group(db, user_id, upg.promo_group_id, commit=False)
removed_any = True
logger.info(
'Удалена старая промогруппа перед автоназначением',
telegram_id=user.telegram_id,
old_group_name=upg.promo_group.name if upg.promo_group else upg.promo_group_id,
old_assigned_by=upg.assigned_by,
)
# Проверяем, есть ли уже эта группа у пользователя
if removed_any:
await db.flush()
await db.refresh(user)
# Проверяем, есть ли уже целевая группа у пользователя
already_has_group = await has_user_promo_group(db, user_id, target_group.id)
if user.auto_promo_group_assigned and already_has_group:
if user.auto_promo_group_assigned and already_has_group and not removed_any:
logger.debug(
"Пользователь уже имеет промогруппу '', повторная выдача не требуется",
'Пользователь уже имеет промогруппу, повторная выдача не требуется',
telegram_id=user.telegram_id,
target_group_name=target_group.name,
)
await sync_user_primary_promo_group(db, user_id)
if target_threshold > previous_threshold:
user.auto_promo_group_threshold_kopeks = target_threshold
user.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(user)
await db.commit()
await db.refresh(user)
return target_group
user.auto_promo_group_assigned = True
user.auto_promo_group_threshold_kopeks = target_threshold
if target_threshold > previous_threshold:
user.auto_promo_group_threshold_kopeks = target_threshold
user.updated_at = datetime.now(UTC)
newly_added = False
if not already_has_group:
# Добавляем новую промогруппу к существующим
await add_user_to_promo_group(db, user_id, target_group.id, assigned_by='auto')
await add_user_to_promo_group(db, user_id, target_group.id, assigned_by='auto', commit=False)
newly_added = True
logger.info(
"🤖 Пользователю добавлена промогруппа '' за траты",
'Пользователю назначена промогруппа за траты',
telegram_id=user.telegram_id,
target_group_name=target_group.name,
total_spent=total_spent / 100,
)
else:
await sync_user_primary_promo_group(db, user_id)
logger.info(
"🤖 Пользователь уже имеет промогруппу '', отмечаем автоприсвоение",
'Пользователь уже имеет промогруппу, синхронизировано',
telegram_id=user.telegram_id,
target_group_name=target_group.name,
)
@@ -169,7 +183,7 @@ async def maybe_assign_promo_group_by_total_spent(
await db.commit()
await db.refresh(user)
if not already_has_group:
if newly_added:
await _notify_admins_about_auto_assignment(
db,
user,
+2 -2
View File
@@ -119,7 +119,7 @@ class PromoCodeService:
if promo_group:
# Add promo group to user
await add_user_to_promo_group(
db, user_id, promocode.promo_group_id, assigned_by='promocode'
db, user_id, promocode.promo_group_id, assigned_by='promocode', commit=False
)
logger.info(
@@ -393,7 +393,7 @@ class PromoCodeService:
has_group = await has_user_promo_group(db, user_id, promocode.promo_group_id)
if has_group:
await remove_user_from_promo_group(db, user_id, promocode.promo_group_id)
await remove_user_from_promo_group(db, user_id, promocode.promo_group_id, commit=False)
logger.info(
'Снята промогруппа ID у пользователя при деактивации промокода',
promo_group_id=promocode.promo_group_id,
+9 -2
View File
@@ -400,9 +400,16 @@ return c
return fail_closed
@staticmethod
async def is_rate_limited(user_id: int, action: str, limit: int, window: int) -> bool:
async def is_rate_limited(
user_id: int,
action: str,
limit: int,
window: int,
*,
fail_closed: bool = False,
) -> bool:
key = cache_key('rate_limit', user_id, action)
return await RateLimitCache._atomic_rate_check(key, limit, window)
return await RateLimitCache._atomic_rate_check(key, limit, window, fail_closed=fail_closed)
@staticmethod
async def reset_rate_limit(user_id: int, action: str) -> bool:
+5 -1
View File
@@ -45,7 +45,11 @@ def run_migrations_offline() -> None:
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
context.configure(
connection=connection,
target_metadata=target_metadata,
transaction_per_migration=True,
)
with context.begin_transaction():
context.run_migrations()
@@ -0,0 +1,56 @@
"""add performance indexes for referral network queries
Revision ID: 0041
Revises: 0040
Create Date: 2026-03-20
"""
from typing import Sequence, Union
from alembic import op
revision: str = '0041'
down_revision: Union[str, None] = '0040'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# CREATE INDEX CONCURRENTLY cannot run inside a transaction.
# autocommit_block() temporarily disables the transaction wrapper.
#
# NOTE: If a concurrent index creation fails midway, PostgreSQL leaves behind
# an INVALID index. Check with:
# SELECT indexrelname FROM pg_stat_user_indexes
# JOIN pg_index ON pg_index.indexrelid = pg_stat_user_indexes.indexrelid
# WHERE NOT pg_index.indisvalid;
# Then drop the invalid index and re-run the migration.
with op.get_context().autocommit_block():
# Index on advertising_campaign_registrations(user_id, created_at)
# Fixes sequential scan in _fetch_campaign_registrations which filters by user_id
# and uses ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at)
op.create_index(
'ix_campaign_reg_user_created',
'advertising_campaign_registrations',
['user_id', 'created_at'],
if_not_exists=True,
postgresql_concurrently=True,
)
# Covering composite index on transactions(user_id, type, is_completed, amount_kopeks)
# Enables index-only scans for aggregation queries in referral network stats:
# _fetch_personal_spent, _fetch_branch_revenue, _fetch_campaign_stats
op.create_index(
'ix_transactions_user_type_completed_amount',
'transactions',
['user_id', 'type', 'is_completed', 'amount_kopeks'],
if_not_exists=True,
postgresql_concurrently=True,
)
def downgrade() -> None:
with op.get_context().autocommit_block():
op.execute('DROP INDEX CONCURRENTLY IF EXISTS ix_transactions_user_type_completed_amount')
op.execute('DROP INDEX CONCURRENTLY IF EXISTS ix_campaign_reg_user_created')
@@ -0,0 +1,88 @@
"""add retry_count to guest_purchases and expression indexes for payment recovery
Revision ID: 0042
Revises: 0041
Create Date: 2026-03-20
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = '0042'
down_revision: Union[str, None] = '0041'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# Payment tables with metadata_json + is_paid column.
_TABLES_WITH_IS_PAID = [
'yookassa_payments',
'mulenpay_payments',
'pal24_payments',
'wata_payments',
'platega_payments',
'cloudpayments_payments',
'freekassa_payments',
'kassa_ai_payments',
'riopay_payments',
'severpay_payments',
]
# All tables that get a metadata purchase_token index (for downgrade)
_ALL_METADATA_TABLES = [*_TABLES_WITH_IS_PAID, 'heleket_payments']
def upgrade() -> None:
# 1. Add retry_count column to guest_purchases (safe: has server_default)
op.add_column(
'guest_purchases',
sa.Column('retry_count', sa.Integer(), nullable=False, server_default='0'),
)
# 2. Create expression indexes for payment recovery queries.
# These allow efficient lookup of succeeded payments by purchase_token
# stored inside the metadata_json column.
with op.get_context().autocommit_block():
# Tables with is_paid boolean column
for table in _TABLES_WITH_IS_PAID:
idx_name = f'ix_{table}_metadata_purchase_token'
op.execute(
sa.text(
f'CREATE INDEX CONCURRENTLY IF NOT EXISTS {idx_name} '
f"ON {table} ((metadata_json ->> 'purchase_token')) "
f'WHERE is_paid = TRUE'
)
)
# Heleket: no is_paid column (it's a Python @property), use status filter
op.execute(
sa.text(
'CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_heleket_payments_metadata_purchase_token '
"ON heleket_payments ((metadata_json ->> 'purchase_token')) "
"WHERE status IN ('paid', 'paid_over')"
)
)
# CryptoBot: payload (text) column with JSON inside, no metadata_json
op.execute(
sa.text(
'CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_cryptobot_payments_payload_purchase_token '
"ON cryptobot_payments ((CAST(payload AS json) ->> 'purchase_token')) "
"WHERE status = 'paid'"
)
)
def downgrade() -> None:
with op.get_context().autocommit_block():
for table in _ALL_METADATA_TABLES:
idx_name = f'ix_{table}_metadata_purchase_token'
op.execute(sa.text(f'DROP INDEX CONCURRENTLY IF EXISTS {idx_name}'))
op.execute(
sa.text('DROP INDEX CONCURRENTLY IF EXISTS ix_cryptobot_payments_payload_purchase_token')
)
op.drop_column('guest_purchases', 'retry_count')
Generated
+1 -1
View File
@@ -1115,7 +1115,7 @@ wheels = [
[[package]]
name = "remnawave-bedolaga-telegram-bot"
version = "3.33.0"
version = "3.34.1"
source = { virtual = "." }
dependencies = [
{ name = "aiogram" },