Merge pull request #2787 from BEDOLAGA-DEV/dev

Dev
This commit is contained in:
Egor
2026-03-21 03:17:44 +03:00
committed by GitHub
76 changed files with 1255 additions and 760 deletions
+8
View File
@@ -13,6 +13,11 @@ SUPPORT_USERNAME=@support
# Имя пользователя бота (опционально, автоопределяется)
# BOT_USERNAME=
# ===== SOCKS5 ПРОКСИ =====
# URL SOCKS5 прокси-сервера для маршрутизации трафика бота к Telegram API
# Формат: socks5://user:password@host:port или socks5://host:port
# PROXY_URL=socks5://127.0.0.1:1080
# ===== СИСТЕМА ПОДДЕРЖКИ =====
# Включить меню поддержки в интерфейсе
SUPPORT_MENU_ENABLED=true
@@ -194,6 +199,9 @@ REMNAWAVE_WEBHOOK_PATH=/remnawave-webhook
# Сгенерируйте: openssl rand -hex 32
# ВАЖНО: этот же секрет указывается в панели Remnawave при создании вебхука
REMNAWAVE_WEBHOOK_SECRET=
# Уведомления администраторам о потере/восстановлении связи с нодами
# false = не отправлять события node.connection_lost / node.connection_restored
REMNAWAVE_WEBHOOK_NOTIFY_NODE_CONNECTION_STATUS=true
# ===== УВЕДОМЛЕНИЯ ОТ ВЕБХУКОВ (что получают пользователи) =====
# Глобальный переключатель уведомлений пользователям от вебхуков
+10 -3
View File
@@ -96,10 +96,17 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
except Exception as e:
logger.warning('Кеш не инициализирован', error=e)
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from app.bot_factory import create_bot
bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
bot = create_bot()
proxy_url = settings.get_proxy_url()
if proxy_url:
from urllib.parse import urlparse
parsed = urlparse(proxy_url)
masked = f'{parsed.scheme}://***@{parsed.hostname}:{parsed.port}' if parsed.username else proxy_url
logger.info('Proxy configured', proxy_url=masked)
maintenance_service.set_bot(bot)
logger.info('Бот установлен в maintenance_service')
+20
View File
@@ -0,0 +1,20 @@
"""Factory for creating Bot instances with proxy support."""
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from app.config import settings
def create_bot(token: str | None = None, **kwargs) -> Bot:
"""Create a Bot instance with SOCKS5 proxy session if PROXY_URL is configured."""
proxy_url = settings.get_proxy_url()
session = None
if proxy_url:
from aiogram.client.session.aiohttp import AiohttpSession
session = AiohttpSession(proxy=proxy_url)
kwargs.setdefault('default', DefaultBotProperties(parse_mode=ParseMode.HTML))
return Bot(token=token or settings.BOT_TOKEN, session=session, **kwargs)
+7
View File
@@ -411,6 +411,13 @@ async def create_broadcast(
media_payload = request.media
# Validate caption length for media messages (Telegram limit: 1024 chars)
if media_payload and len(message_text) > 1024:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Текст слишком длинный для сообщения с медиа. Максимум 1024 символов, сейчас {len(message_text)}. Сократите текст или уберите медиафайл.',
)
# Create broadcast record
broadcast = BroadcastHistory(
target_type=request.target,
+3 -1
View File
@@ -312,7 +312,7 @@ TEMPLATE_TYPES = [
'zh': '通过落地页成功付款后发送给买家的邮件',
'ua': 'Лист покупцю після успішної оплати через лендінг',
},
'context_vars': ['tariff_name', 'period_days', 'cabinet_url'],
'context_vars': ['tariff_name', 'period_days', 'cabinet_url', 'cabinet_email', 'cabinet_password'],
},
{
'type': 'guest_activation_required',
@@ -425,6 +425,8 @@ SAMPLE_CONTEXTS: dict[str, dict[str, Any]] = {
'tariff_name': 'Premium',
'period_days': 30,
'cabinet_url': 'https://example.com/cabinet',
'cabinet_email': 'user@example.com',
'cabinet_password': 'SecurePass123',
},
'guest_activation_required': {
'tariff_name': 'Premium',
+21 -1
View File
@@ -483,6 +483,7 @@ class OrderRequest(BaseModel):
class LandingDailyStat(BaseModel):
date: str # YYYY-MM-DD
created: int = 0
purchases: int
revenue_kopeks: int
gifts: int
@@ -844,17 +845,35 @@ async def get_landing_stats(
)
daily_rows = {str(r.day): r for r in daily_result.all()}
# Created per day (all statuses, by created_at)
day_created_utc = func.date(func.timezone('UTC', GuestPurchase.created_at))
created_result = await db.execute(
select(
day_created_utc.label('day'),
func.count(GuestPurchase.id).label('created'),
)
.where(
GuestPurchase.landing_id == landing_id,
GuestPurchase.created_at >= cutoff,
)
.group_by(day_created_utc)
.order_by(day_created_utc)
)
created_rows = {str(r.day): r.created for r in created_result.all()}
# Fill missing days with zeros
today = now.date()
daily_stats: list[LandingDailyStat] = []
for i in range(_STATS_PERIOD_DAYS, -1, -1):
day = today - timedelta(days=i)
day_str = day.isoformat()
day_created = created_rows.get(day_str, 0)
if day_str in daily_rows:
r = daily_rows[day_str]
daily_stats.append(
LandingDailyStat(
date=day_str,
created=day_created,
purchases=r.purchases,
revenue_kopeks=r.revenue_kopeks,
gifts=r.gifts,
@@ -864,6 +883,7 @@ async def get_landing_stats(
daily_stats.append(
LandingDailyStat(
date=day_str,
created=day_created,
purchases=0,
revenue_kopeks=0,
gifts=0,
@@ -897,7 +917,7 @@ async def get_landing_stats(
]
return LandingStatsResponse(
total_purchases=total_successful,
total_purchases=total_created,
total_revenue_kopeks=total_revenue_kopeks,
total_gifts=total_gifts,
total_regular=total_regular,
+4 -6
View File
@@ -227,8 +227,7 @@ async def approve_application(
# Notify user about approval
try:
from aiogram import Bot
from app.bot_factory import create_bot
from app.config import settings
from app.services.notification_delivery_service import notification_delivery_service
@@ -240,7 +239,7 @@ async def approve_application(
tg_message = (
f'✅ Ваша заявка на партнёрство одобрена!\nКомиссия: {request.commission_percent}%{comment_text}'
)
bot = Bot(token=settings.BOT_TOKEN)
bot = create_bot()
try:
await notification_delivery_service.notify_partner_approved(
user=user,
@@ -280,8 +279,7 @@ async def reject_application(
# Notify user about rejection
try:
from aiogram import Bot
from app.bot_factory import create_bot
from app.config import settings
from app.services.notification_delivery_service import notification_delivery_service
@@ -291,7 +289,7 @@ async def reject_application(
if user:
comment_text = f'\nПричина: {request.comment}' if request.comment else ''
tg_message = f'❌ Ваша заявка на партнёрство отклонена.{comment_text}'
bot = Bot(token=settings.BOT_TOKEN)
bot = create_bot()
try:
await notification_delivery_service.notify_partner_rejected(
user=user,
+2 -5
View File
@@ -4,14 +4,11 @@ import math
from datetime import UTC, datetime, timedelta
import structlog
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.bot_factory import create_bot
from app.database.models import PaymentMethod, User
from app.services.payment_search_service import (
MAX_ALL_TIME_DAYS,
@@ -550,7 +547,7 @@ async def check_payment_status(
old_is_paid = record.is_paid
# Run manual check
bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
bot = create_bot()
try:
payment_service = PaymentService(bot=bot)
updated = await run_manual_check(db, payment_method, payment_id, payment_service)
+2 -7
View File
@@ -5,13 +5,11 @@ from datetime import UTC, datetime
import structlog
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.bot_factory import create_bot
from app.database.models import PinnedMessage, User
from app.services.pinned_message_service import (
broadcast_pinned_message,
@@ -77,10 +75,7 @@ _cached_bot: Bot | None = None
def _get_bot() -> Bot:
global _cached_bot
if _cached_bot is None:
_cached_bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
_cached_bot = create_bot()
return _cached_bot
+2 -7
View File
@@ -8,15 +8,13 @@ from typing import Any
import structlog
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.bot_factory import create_bot
from app.database.crud.discount_offer import (
count_discount_offers,
list_discount_offers,
@@ -369,10 +367,7 @@ async def list_offers(
def _get_bot() -> Bot:
"""Create bot instance for sending notifications."""
return Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
return create_bot()
def _build_default_promo_message(
+61 -19
View File
@@ -4,12 +4,13 @@ from __future__ import annotations
from datetime import datetime
import sqlalchemy as sa
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.rbac import AdminRoleCRUD, UserRoleCRUD
from app.database.crud.rbac import SUPERADMIN_LEVEL, AdminRoleCRUD, UserRoleCRUD
from app.database.models import User
from app.services.permission_service import PERMISSION_REGISTRY, get_all_permissions
@@ -129,21 +130,26 @@ async def _role_to_response(db: AsyncSession, role) -> RoleResponse:
async def _get_admin_level(db: AsyncSession, admin: User) -> int:
"""Get the maximum role level of the current admin.
"""Get the effective management level of the current admin.
Legacy config-based admins (ADMIN_IDS) get superadmin level (999+1=1000)
so they can manage all roles including level 999.
Superadmin-tier users (DB level 999 or legacy ADMIN_IDS) are promoted to
level 1000 so they can manage peer Superadmins. Without this, the ``>=``
hierarchy guard would block 999-vs-999 operations.
"""
from app.config import settings
_perms, _names, max_level = await UserRoleCRUD.get_user_permissions(db, admin.id)
# DB-assigned Superadmins can manage peers
if max_level >= SUPERADMIN_LEVEL:
max_level = SUPERADMIN_LEVEL + 1
# Legacy config-based admins always get the highest level
if settings.is_admin(
telegram_id=admin.telegram_id,
email=admin.email if admin.email_verified else None,
):
max_level = max(max_level, 1000)
max_level = max(max_level, SUPERADMIN_LEVEL + 1)
return max_level
@@ -339,6 +345,15 @@ async def update_role(
update_data = payload.model_dump(exclude_unset=True)
# System roles: only permissions can be extended, block is_active/level changes
if role.is_system:
blocked = {'is_active', 'level'} & update_data.keys()
if blocked:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f'Cannot change {", ".join(sorted(blocked))} on a system role',
)
# Validate level change
if 'level' in update_data and update_data['level'] >= admin_level:
raise HTTPException(
@@ -435,6 +450,13 @@ async def assign_role(
detail='Cannot assign a role with level >= your own role level',
)
# Superadmin assignments must be permanent — expiry would cause silent lockout
if role.level == SUPERADMIN_LEVEL and payload.expires_at is not None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Superadmin role assignments cannot be time-limited',
)
# Verify target user exists
from app.database.crud.user import get_user_by_id
@@ -484,12 +506,12 @@ async def revoke_role(
db: AsyncSession = Depends(get_cabinet_db),
):
"""Revoke a role assignment. Cannot remove the last superadmin."""
from sqlalchemy import select as sa_select
from app.config import settings
from app.database.crud.user import get_user_by_id
from app.database.models import UserRole
# Load the assignment to check hierarchy
result = await db.execute(sa_select(UserRole).where(UserRole.id == assignment_id))
# Lock the assignment row (FOR UPDATE held until commit)
result = await db.execute(sa.select(UserRole).where(UserRole.id == assignment_id).with_for_update())
user_role = result.scalar_one_or_none()
if not user_role:
raise HTTPException(
@@ -513,9 +535,19 @@ async def revoke_role(
detail='Cannot revoke a role at or above your own level',
)
# Protect last superadmin (level 999)
superadmin_level = 999
if role.level == superadmin_level:
# Block self-revocation of superadmin role
if role.level == SUPERADMIN_LEVEL and user_role.user_id == admin.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Cannot revoke your own superadmin role',
)
# Protect last superadmin (level 999).
# Advisory lock serializes concurrent superadmin revocations so two requests
# cannot both read count=2 and then both proceed to revoke.
if role.level == SUPERADMIN_LEVEL:
if not settings.is_sqlite():
await db.execute(sa.text('SELECT pg_advisory_xact_lock(736453)'))
superadmin_count = await UserRoleCRUD.get_superadmin_count(db)
if superadmin_count <= 1:
raise HTTPException(
@@ -523,13 +555,16 @@ async def revoke_role(
detail='Cannot remove the last superadmin',
)
revoked = await UserRoleCRUD.revoke_role(db, assignment_id)
if not revoked:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Failed to revoke role',
)
# Warn if target user is a legacy admin — RBAC revocation won't actually block access
target_user = await get_user_by_id(db, user_role.user_id)
is_target_legacy = target_user and settings.is_admin(
telegram_id=target_user.telegram_id,
email=target_user.email if target_user.email_verified else None,
)
# Revoke directly on the locked object (avoid CRUD re-fetch without FOR UPDATE)
user_role.is_active = False
await db.flush()
await db.commit()
logger.info(
@@ -539,4 +574,11 @@ async def revoke_role(
target_user_id=user_role.user_id,
role_name=role.name,
)
return {'message': 'Role revoked', 'assignment_id': assignment_id}
result_msg = {'message': 'Role revoked', 'assignment_id': assignment_id}
if is_target_legacy:
result_msg['warning'] = (
'This user is still listed in ADMIN_IDS/ADMIN_EMAILS env config. '
'They retain full access until removed from those settings and the bot is restarted.'
)
return result_msg
+5 -5
View File
@@ -112,11 +112,11 @@ async def get_sales_summary(
try:
period_start, period_end = _parse_period(days, start_date, end_date)
# Total revenue (deposits with real payment methods)
# Total revenue (deposits + direct subscription payments with real payment methods)
revenue_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
and_(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]),
Transaction.is_completed == True,
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
Transaction.created_at >= period_start,
@@ -1079,7 +1079,7 @@ async def get_deposits_stats(
methods_with_manual = [*REAL_PAYMENT_METHODS, PaymentMethod.MANUAL.value]
base_filter = and_(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]),
Transaction.is_completed == True,
Transaction.payment_method.in_(methods_with_manual),
Transaction.created_at >= period_start,
@@ -1089,7 +1089,7 @@ async def get_deposits_stats(
totals_result = await db.execute(
select(
func.count(Transaction.id).label('count'),
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('amount'),
func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0).label('amount'),
).where(base_filter)
)
totals = totals_result.one()
+14 -6
View File
@@ -275,6 +275,14 @@ async def get_dashboard_stats(
# Get tariff statistics
tariff_stats = await _get_tariff_stats(db)
# Derive income_today from revenue_chart to ensure consistency with chart
today_str = now.date().isoformat()
income_today_from_chart = sum(
item.get('amount_kopeks', 0) for item in revenue_data if str(item.get('date', '')) == today_str
)
# Use chart-derived value if available, otherwise fall back to trans_stats
income_today_kopeks = income_today_from_chart or trans_stats.get('today', {}).get('income_kopeks', 0)
# Build response
return DashboardStats(
nodes=nodes_data,
@@ -290,8 +298,8 @@ async def get_dashboard_stats(
trial_to_paid_conversion=sub_stats.get('trial_to_paid_conversion', 0.0),
),
financial=FinancialStats(
income_today_kopeks=trans_stats.get('today', {}).get('income_kopeks', 0),
income_today_rubles=trans_stats.get('today', {}).get('income_kopeks', 0) / 100,
income_today_kopeks=income_today_kopeks,
income_today_rubles=income_today_kopeks / 100,
income_month_kopeks=trans_stats.get('totals', {}).get('income_kopeks', 0),
income_month_rubles=trans_stats.get('totals', {}).get('income_kopeks', 0) / 100,
income_total_kopeks=all_time_stats.get('totals', {}).get('income_kopeks', 0),
@@ -926,9 +934,9 @@ async def get_recent_payments(
total_count = total_count_result.scalar() or 0
today_total_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
and_(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]),
Transaction.is_completed == True,
Transaction.created_at >= today_start,
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
@@ -938,9 +946,9 @@ async def get_recent_payments(
total_today = today_total_result.scalar() or 0
week_total_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
and_(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]),
Transaction.is_completed == True,
Transaction.created_at >= week_ago,
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
+2 -7
View File
@@ -479,14 +479,9 @@ async def reply_to_ticket(
# Try to notify user via Telegram
try:
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from app.bot_factory import create_bot
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
bot = create_bot()
try:
from app.handlers.admin.tickets import notify_user_about_ticket_reply
+2 -8
View File
@@ -7,16 +7,13 @@ import time
from datetime import UTC, datetime, timedelta
import structlog
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram.types import BufferedInputFile
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import settings
from app.bot_factory import create_bot
from app.database.models import Subscription, Transaction, TransactionType, User
from app.services.remnawave_service import RemnaWaveService
@@ -680,10 +677,7 @@ async def export_traffic_csv(
filename = f'traffic_usage_{period_label}_{timestamp}.csv'
try:
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
bot = create_bot()
async with bot:
await bot.send_document(
chat_id=admin.telegram_id,
+4 -6
View File
@@ -199,8 +199,7 @@ async def approve_withdrawal(
# Notify user about approval
try:
from aiogram import Bot
from app.bot_factory import create_bot
from app.config import settings
from app.services.notification_delivery_service import notification_delivery_service
@@ -211,7 +210,7 @@ async def approve_withdrawal(
formatted_amount = settings.format_price(withdrawal.amount_kopeks)
comment_text = f'\n{request.comment}' if request.comment else ''
tg_message = f'✅ Ваш запрос на вывод {formatted_amount} одобрен.{comment_text}'
bot = Bot(token=settings.BOT_TOKEN)
bot = create_bot()
try:
await notification_delivery_service.notify_withdrawal_approved(
user=user,
@@ -251,8 +250,7 @@ async def reject_withdrawal(
# Notify user about rejection
try:
from aiogram import Bot
from app.bot_factory import create_bot
from app.config import settings
from app.services.notification_delivery_service import notification_delivery_service
@@ -263,7 +261,7 @@ async def reject_withdrawal(
formatted_amount = settings.format_price(withdrawal.amount_kopeks)
comment_text = f'\nПричина: {request.comment}' if request.comment else ''
tg_message = f'❌ Ваш запрос на вывод {formatted_amount} отклонён.{comment_text}'
bot = Bot(token=settings.BOT_TOKEN)
bot = create_bot()
try:
await notification_delivery_service.notify_withdrawal_rejected(
user=user,
+10 -15
View File
@@ -196,12 +196,10 @@ async def _process_campaign_bonus(
user.referred_by_id = campaign.partner_user_id
await db.flush()
try:
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from app.bot_factory import create_bot
bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
await process_referral_registration(db, user.id, campaign.partner_user_id, bot=bot)
async with create_bot() as bot:
await process_referral_registration(db, user.id, campaign.partner_user_id, bot=bot)
logger.info(
'Referral set from campaign partner',
user_id=user.id,
@@ -255,12 +253,11 @@ async def _process_referral_code(
return
user.referred_by_id = referrer.id
await db.flush()
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
await process_referral_registration(db, user.id, referrer.id, bot=bot)
from app.bot_factory import create_bot
async with create_bot() as bot:
await process_referral_registration(db, user.id, referrer.id, bot=bot)
logger.info('Referral applied from code', user_id=user.id, referrer_id=referrer.id, referral_code=referral_code)
except Exception as e:
logger.error('Failed to process referral code', error=e, referral_code=referral_code)
@@ -937,12 +934,10 @@ async def register_email_standalone(
# Обработать реферальную регистрацию (если есть реферер)
if referrer:
try:
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from app.bot_factory import create_bot
bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
await process_referral_registration(db, user.id, referrer.id, bot=bot)
async with create_bot() as bot:
await process_referral_registration(db, user.id, referrer.id, bot=bot)
logger.info(
'Processed referral registration: user_id=, referrer_id', user_id=user.id, referrer_id=referrer.id
)
+26 -42
View File
@@ -4,15 +4,12 @@ import math
import time
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
import httpx
import structlog
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.bot_factory import create_bot
from app.config import settings
from app.database.crud.saved_payment_method import (
deactivate_payment_method,
@@ -272,50 +269,37 @@ async def create_stars_invoice(
# Create invoice through Telegram Bot API
try:
bot_token = settings.BOT_TOKEN
api_url = f'https://api.telegram.org/bot{bot_token}/createInvoiceLink'
from aiogram.exceptions import TelegramAPIError
from aiogram.types import LabeledPrice
async with httpx.AsyncClient() as client:
response = await client.post(
api_url,
json={
'title': 'Пополнение баланса VPN',
'description': f'Пополнение баланса на {normalized_kopeks / 100:.2f} ₽ ({stars_amount} ⭐)',
'payload': payload,
'provider_token': '', # Empty for Stars
'currency': 'XTR',
'prices': [{'label': 'Пополнение баланса', 'amount': stars_amount}],
},
async with create_bot() as bot:
invoice_url = await bot.create_invoice_link(
title='Пополнение баланса VPN',
description=f'Пополнение баланса на {normalized_kopeks / 100:.2f} ₽ ({stars_amount} ⭐)',
payload=payload,
provider_token='',
currency='XTR',
prices=[LabeledPrice(label='Пополнение баланса', amount=stars_amount)],
)
result = response.json()
logger.info(
'Created Stars invoice for balance top-up: user=, amount= kopeks, stars',
user_id=user.id,
amount_kopeks=request.amount_kopeks,
stars_amount=stars_amount,
)
if not result.get('ok'):
logger.error('Telegram API error', result=result)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to create Stars invoice',
)
return StarsInvoiceResponse(
invoice_url=invoice_url,
stars_amount=stars_amount,
amount_kopeks=normalized_kopeks,
)
invoice_url = result['result']
logger.info(
'Created Stars invoice for balance top-up: user=, amount= kopeks, stars',
user_id=user.id,
amount_kopeks=request.amount_kopeks,
stars_amount=stars_amount,
)
return StarsInvoiceResponse(
invoice_url=invoice_url,
stars_amount=stars_amount,
amount_kopeks=normalized_kopeks,
)
except httpx.HTTPError as e:
logger.error('HTTP error creating Stars invoice', error=e)
except TelegramAPIError as e:
logger.error('Error creating Stars invoice', error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to connect to Telegram API',
detail='Failed to create Stars invoice',
)
@@ -1202,7 +1186,7 @@ async def check_payment_status(
old_is_paid = record.is_paid
# Run manual check
bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
bot = create_bot()
try:
payment_service = PaymentService(bot=bot)
updated = await run_manual_check(db, payment_method, payment_id, payment_service)
+17 -13
View File
@@ -306,9 +306,9 @@ async def create_gift_purchase(
else:
# 2) Fall back to Bot API (works for public usernames the bot has seen)
try:
from aiogram import Bot
from app.bot_factory import create_bot
async with Bot(token=settings.BOT_TOKEN) as bot:
async with create_bot() as bot:
chat = await asyncio.wait_for(bot.get_chat(chat_id=f'@{tg_username}'), timeout=5.0)
pre_resolved_telegram_id = chat.id
except Exception:
@@ -371,19 +371,23 @@ async def create_gift_purchase(
# Stars payments need a Bot instance to create invoice links
bot = None
if body.payment_method == 'telegram_stars':
from aiogram import Bot
from app.bot_factory import create_bot
bot = Bot(token=settings.BOT_TOKEN)
bot = create_bot()
payment_service = PaymentService(bot=bot)
payment_result = await payment_service.create_guest_payment(
db=db,
amount_kopeks=price_kopeks,
payment_method=body.payment_method,
description=f'Gift: {tariff.name} ({body.period_days}d)',
purchase_token=purchase.token,
return_url=return_url,
)
try:
payment_service = PaymentService(bot=bot)
payment_result = await payment_service.create_guest_payment(
db=db,
amount_kopeks=price_kopeks,
payment_method=body.payment_method,
description=f'Gift: {tariff.name} ({body.period_days}d)',
purchase_token=purchase.token,
return_url=return_url,
)
finally:
if bot:
await bot.session.close()
if payment_result is None:
await db.rollback()
+1 -1
View File
@@ -550,7 +550,7 @@ async def create_landing_purchase(
No authentication required.
"""
client_ip = get_client_ip(raw_request)
if await RateLimitCache.is_ip_rate_limited(client_ip, 'landing_purchase', limit=5, window=60, fail_closed=True):
if await RateLimitCache.is_ip_rate_limited(client_ip, 'landing_purchase', limit=30, window=60, fail_closed=True):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail='Too many purchase attempts, please try again later',
+3 -11
View File
@@ -3,13 +3,11 @@
import mimetypes
import structlog
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram.types import BufferedInputFile
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, Response, UploadFile, status
from pydantic import BaseModel
from app.bot_factory import create_bot
from app.config import settings
from app.database.models import User
@@ -98,10 +96,7 @@ async def upload_media(
target_chat_id = _resolve_target_chat_id()
upload = BufferedInputFile(file_bytes, filename=file.filename or 'upload')
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
bot = create_bot()
try:
if media_type_normalized == 'photo':
@@ -158,10 +153,7 @@ async def download_media(
Download media file by file_id.
Used to display images/documents in ticket messages.
"""
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
bot = create_bot()
try:
file = await bot.get_file(file_id)
+2 -3
View File
@@ -178,12 +178,11 @@ async def apply_for_partner(
# Уведомляем админов о новой заявке
try:
from aiogram import Bot
from app.bot_factory import create_bot
from app.services.admin_notification_service import AdminNotificationService
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
bot = Bot(token=settings.BOT_TOKEN)
bot = create_bot()
try:
notification_service = AdminNotificationService(bot)
await notification_service.send_partner_application_notification(
+2 -8
View File
@@ -92,14 +92,8 @@ async def get_referral_info(
available_balance = min(user.balance_kopeks, referral_entitlement)
# Build referral links
referral_link = settings.get_referral_link(user.referral_code) if user.referral_code else ''
bot_username = settings.get_bot_username()
bot_referral_link = ''
if user.referral_code and bot_username:
from urllib.parse import quote
safe_code = quote(user.referral_code, safe='')
bot_referral_link = f'https://t.me/{bot_username}?start={safe_code}'
referral_link = (settings.get_cabinet_referral_link(user.referral_code) or '') if user.referral_code else ''
bot_referral_link = settings.get_bot_referral_link(user.referral_code) if user.referral_code else ''
return ReferralInfoResponse(
referral_code=user.referral_code or '',
+14 -21
View File
@@ -815,12 +815,11 @@ async def purchase_traffic(
# Отправляем уведомление админам
try:
from aiogram import Bot
from app.bot_factory import create_bot
from app.services.admin_notification_service import AdminNotificationService
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
bot = Bot(token=settings.BOT_TOKEN)
bot = create_bot()
try:
notification_service = AdminNotificationService(bot)
old_traffic = subscription.traffic_limit_gb - request.gb
@@ -1043,12 +1042,11 @@ async def purchase_devices_legacy(
# Отправляем уведомление админам
try:
from aiogram import Bot
from app.bot_factory import create_bot
from app.services.admin_notification_service import AdminNotificationService
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
bot = Bot(token=settings.BOT_TOKEN)
bot = create_bot()
try:
notification_service = AdminNotificationService(bot)
await notification_service.send_subscription_update_notification(
@@ -1344,12 +1342,11 @@ async def activate_trial(
# Send admin notification about trial activation
try:
from aiogram import Bot
from app.bot_factory import create_bot
from app.services.admin_notification_service import AdminNotificationService
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
bot = Bot(token=settings.BOT_TOKEN)
bot = create_bot()
try:
notification_service = AdminNotificationService(bot)
charged_amount = settings.TRIAL_ACTIVATION_PRICE if requires_payment else None
@@ -1763,12 +1760,11 @@ async def submit_purchase(
# Отправляем уведомление админам о покупке подписки
try:
from aiogram import Bot
from app.bot_factory import create_bot
from app.services.admin_notification_service import AdminNotificationService
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
bot = Bot(token=settings.BOT_TOKEN)
bot = create_bot()
try:
notification_service = AdminNotificationService(bot)
is_new_subscription = result.get('was_trial_conversion') or not context.subscription
@@ -2161,12 +2157,11 @@ async def purchase_tariff(
# Отправляем уведомление админам о покупке/продлении тарифа
try:
from aiogram import Bot
from app.bot_factory import create_bot
from app.services.admin_notification_service import AdminNotificationService
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
bot = Bot(token=settings.BOT_TOKEN)
bot = create_bot()
try:
notification_service = AdminNotificationService(bot)
# Определяем тип покупки: новая подписка или продление
@@ -2418,12 +2413,11 @@ async def purchase_devices(
# Отправляем уведомление админам
try:
from aiogram import Bot
from app.bot_factory import create_bot
from app.services.admin_notification_service import AdminNotificationService
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
bot = Bot(token=settings.BOT_TOKEN)
bot = create_bot()
try:
notification_service = AdminNotificationService(bot)
await notification_service.send_subscription_update_notification(
@@ -4204,12 +4198,11 @@ async def switch_tariff(
# Отправляем уведомление админам о смене тарифа
try:
from aiogram import Bot
from app.bot_factory import create_bot
from app.services.admin_notification_service import AdminNotificationService
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
bot = Bot(token=settings.BOT_TOKEN)
bot = create_bot()
try:
notification_service = AdminNotificationService(bot)
await notification_service.send_subscription_purchase_notification(
+20 -35
View File
@@ -5,7 +5,6 @@ API роуты колеса удачи для пользователей.
import math
import time
import httpx
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
@@ -21,7 +20,6 @@ from app.cabinet.schemas.wheel import (
WheelConfigResponse,
WheelPrizeDisplay,
)
from app.config import settings
from app.database.crud.wheel import (
get_or_create_wheel_config,
get_user_spin_history,
@@ -251,44 +249,31 @@ async def create_stars_invoice(
# Создаем invoice через Telegram Bot API
try:
bot_token = settings.BOT_TOKEN
api_url = f'https://api.telegram.org/bot{bot_token}/createInvoiceLink'
from aiogram.exceptions import TelegramAPIError
from aiogram.types import LabeledPrice
async with httpx.AsyncClient() as client:
response = await client.post(
api_url,
json={
'title': 'Колесо удачи',
'description': f'Спин колеса удачи ({stars_amount} ⭐)',
'payload': payload,
'provider_token': '', # Пустой для Stars
'currency': 'XTR',
'prices': [{'label': 'Спин колеса', 'amount': stars_amount}],
},
from app.bot_factory import create_bot
async with create_bot() as bot:
invoice_url = await bot.create_invoice_link(
title='Колесо удачи',
description=f'Спин колеса удачи ({stars_amount} ⭐)',
payload=payload,
provider_token='',
currency='XTR',
prices=[LabeledPrice(label='Спин колеса', amount=stars_amount)],
)
result = response.json()
logger.info('Created Stars invoice for wheel spin: user=, stars', user_id=user.id, stars_amount=stars_amount)
if not result.get('ok'):
logger.error('Telegram API error', result=result)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Ошибка создания инвойса',
)
return StarsInvoiceResponse(
invoice_url=invoice_url,
stars_amount=stars_amount,
)
invoice_url = result['result']
logger.info(
'Created Stars invoice for wheel spin: user=, stars', user_id=user.id, stars_amount=stars_amount
)
return StarsInvoiceResponse(
invoice_url=invoice_url,
stars_amount=stars_amount,
)
except httpx.HTTPError as e:
logger.error('HTTP error creating invoice', error=e)
except TelegramAPIError as e:
logger.error('Error creating invoice', error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Ошибка соединения с Telegram',
detail='Ошибка создания инвойса',
)
+2 -3
View File
@@ -70,12 +70,11 @@ async def create_withdrawal(
# Уведомляем админов о запросе на вывод
try:
from aiogram import Bot
from app.bot_factory import create_bot
from app.services.admin_notification_service import AdminNotificationService
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
bot = Bot(token=settings.BOT_TOKEN)
bot = create_bot()
try:
notification_service = AdminNotificationService(bot)
await notification_service.send_withdrawal_request_notification(
+35 -11
View File
@@ -17,14 +17,33 @@ logger = structlog.get_logger(__name__)
class EmailService:
"""Service for sending emails via SMTP."""
def __init__(self):
self.host = settings.SMTP_HOST
self.port = settings.SMTP_PORT
self.user = settings.SMTP_USER
self.password = settings.SMTP_PASSWORD
self.from_email = settings.get_smtp_from_email()
self.from_name = settings.SMTP_FROM_NAME
self.use_tls = settings.SMTP_USE_TLS
@property
def host(self) -> str | None:
return settings.SMTP_HOST
@property
def port(self) -> int:
return settings.SMTP_PORT
@property
def user(self) -> str | None:
return settings.SMTP_USER
@property
def password(self) -> str | None:
return settings.SMTP_PASSWORD
@property
def from_email(self) -> str | None:
return settings.get_smtp_from_email()
@property
def from_name(self) -> str:
return settings.SMTP_FROM_NAME
@property
def use_tls(self) -> bool:
return settings.SMTP_USE_TLS
def is_configured(self) -> bool:
"""Check if SMTP is properly configured."""
@@ -71,6 +90,11 @@ class EmailService:
logger.warning('SMTP is not configured, cannot send email')
return False
sender_email = self.from_email
if not sender_email or '@' not in sender_email:
logger.error('Invalid or missing SMTP from_email, cannot send email', from_email=sender_email)
return False
# Defensive: strip newlines to prevent header injection
to_email = to_email.strip().replace('\n', '').replace('\r', '')
subject = subject.replace('\n', '').replace('\r', '')
@@ -79,11 +103,11 @@ class EmailService:
msg = MIMEMultipart('alternative')
msg['Subject'] = subject
safe_from_name = self.from_name.replace('\n', '').replace('\r', '') if self.from_name else ''
safe_from_email = self.from_email.replace('\n', '').replace('\r', '') if self.from_email else ''
safe_from_email = sender_email.replace('\n', '').replace('\r', '')
msg['From'] = f'{safe_from_name} <{safe_from_email}>'
msg['To'] = to_email
msg['Date'] = formatdate(localtime=False)
msg['Message-ID'] = make_msgid(domain=self.from_email.split('@')[-1])
msg['Message-ID'] = make_msgid(domain=safe_from_email.split('@')[-1])
# Plain text version
if body_text is None:
@@ -103,7 +127,7 @@ class EmailService:
msg.attach(part2)
with self._get_smtp_connection() as smtp:
smtp.sendmail(self.from_email, to_email, msg.as_string())
smtp.sendmail(safe_from_email, to_email, msg.as_string())
logger.info('Email sent successfully to', to_email=to_email)
return True
+67
View File
@@ -1373,6 +1373,8 @@ class EmailNotificationTemplates:
tariff_name = html.escape(context.get('tariff_name', ''))
period_days = context.get('period_days', 0)
cabinet_url = html.escape(context.get('cabinet_url', ''))
cabinet_email = html.escape(context.get('cabinet_email', ''))
cabinet_password = context.get('cabinet_password', '')
subjects = {
'ru': 'Ваша VPN подписка готова',
@@ -1382,6 +1384,66 @@ class EmailNotificationTemplates:
'fa': 'اشتراک VPN شما آماده است',
}
creds_block_ru = (
f"""
<div class="highlight">
<p><strong>Данные для входа в личный кабинет:</strong></p>
<p><strong>Email:</strong> <code>{cabinet_email}</code></p>
<p><strong>Пароль:</strong> <code>{cabinet_password}</code></p>
</div>
"""
if cabinet_password
else ''
)
creds_block_en = (
f"""
<div class="highlight">
<p><strong>Your cabinet login credentials:</strong></p>
<p><strong>Email:</strong> <code>{cabinet_email}</code></p>
<p><strong>Password:</strong> <code>{cabinet_password}</code></p>
</div>
"""
if cabinet_password
else ''
)
creds_block_zh = (
f"""
<div class="highlight">
<p><strong>个人中心登录信息</strong></p>
<p><strong>Email:</strong> <code>{cabinet_email}</code></p>
<p><strong>密码:</strong> <code>{cabinet_password}</code></p>
</div>
"""
if cabinet_password
else ''
)
creds_block_ua = (
f"""
<div class="highlight">
<p><strong>Дані для входу в особистий кабінет:</strong></p>
<p><strong>Email:</strong> <code>{cabinet_email}</code></p>
<p><strong>Пароль:</strong> <code>{cabinet_password}</code></p>
</div>
"""
if cabinet_password
else ''
)
creds_block_fa = (
f"""
<div class="highlight">
<p><strong>اطلاعات ورود به پنل کاربری:</strong></p>
<p><strong>Email:</strong> <code>{cabinet_email}</code></p>
<p><strong>رمز عبور:</strong> <code>{cabinet_password}</code></p>
</div>
"""
if cabinet_password
else ''
)
bodies = {
'ru': f"""
<h2>Ваша VPN подписка готова!</h2>
@@ -1389,6 +1451,7 @@ class EmailNotificationTemplates:
<p>Тариф: <strong>{tariff_name}</strong></p>
<p>Период: <strong>{period_days} дней</strong></p>
</div>
{creds_block_ru}
<p>Подписка активирована в вашем личном кабинете.</p>
<p style="text-align: center;"><a href="{cabinet_url}" class="button">Перейти в личный кабинет</a></p>
""",
@@ -1398,6 +1461,7 @@ class EmailNotificationTemplates:
<p>Plan: <strong>{tariff_name}</strong></p>
<p>Period: <strong>{period_days} days</strong></p>
</div>
{creds_block_en}
<p>Your subscription has been activated in your cabinet.</p>
<p style="text-align: center;"><a href="{cabinet_url}" class="button">Go to Cabinet</a></p>
""",
@@ -1407,6 +1471,7 @@ class EmailNotificationTemplates:
<p>套餐: <strong>{tariff_name}</strong></p>
<p>期限: <strong>{period_days} </strong></p>
</div>
{creds_block_zh}
<p>订阅已在您的个人中心激活</p>
<p style="text-align: center;"><a href="{cabinet_url}" class="button">前往个人中心</a></p>
""",
@@ -1416,6 +1481,7 @@ class EmailNotificationTemplates:
<p>Тариф: <strong>{tariff_name}</strong></p>
<p>Період: <strong>{period_days} днів</strong></p>
</div>
{creds_block_ua}
<p>Підписка активована у вашому особистому кабінеті.</p>
<p style="text-align: center;"><a href="{cabinet_url}" class="button">Перейти до кабінету</a></p>
""",
@@ -1425,6 +1491,7 @@ class EmailNotificationTemplates:
<p>طرح: <strong>{tariff_name}</strong></p>
<p>مدت: <strong>{period_days} روز</strong></p>
</div>
{creds_block_fa}
<p>اشتراک شما در پنل کاربری فعال شده است.</p>
<p style="text-align: center;"><a href="{cabinet_url}" class="button">رفتن به پنل کاربری</a></p>
""",
+56 -10
View File
@@ -7,7 +7,7 @@ from collections import defaultdict
from datetime import time
from pathlib import Path
from typing import Literal
from urllib.parse import urlparse
from urllib.parse import quote as _url_quote, urlparse
from zoneinfo import ZoneInfo
import structlog
@@ -118,6 +118,7 @@ class Settings(BaseSettings):
REMNAWAVE_WEBHOOK_ENABLED: bool = False
REMNAWAVE_WEBHOOK_PATH: str = '/remnawave-webhook'
REMNAWAVE_WEBHOOK_SECRET: str | None = None # HMAC-SHA256 shared secret (min 32 chars)
REMNAWAVE_WEBHOOK_NOTIFY_NODE_CONNECTION_STATUS: bool = True
# Webhook user notification toggles (what Telegram messages users receive from webhook events)
WEBHOOK_NOTIFY_USER_ENABLED: bool = True
@@ -802,6 +803,27 @@ class Settings(BaseSettings):
BAN_SYSTEM_API_TOKEN: str | None = None
BAN_SYSTEM_REQUEST_TIMEOUT: int = 30
# SOCKS5 proxy for routing bot traffic to Telegram API
# Format: socks5://user:password@host:port or socks5://host:port
PROXY_URL: str | None = None
@field_validator('PROXY_URL', mode='before')
@classmethod
def validate_proxy_url(cls, value: str | None) -> str | None:
if not value:
return None
from urllib.parse import urlparse
parsed = urlparse(value)
if parsed.scheme not in ('socks5', 'socks4'):
raise ValueError(
f'PROXY_URL must use socks5:// or socks4:// scheme, got: {parsed.scheme!r}. '
'HTTP proxies are not supported for security reasons (bot token would be exposed).'
)
if not parsed.hostname:
raise ValueError('PROXY_URL must contain a hostname')
return value
@field_validator('MAIN_MENU_MODE', mode='before')
@classmethod
def normalize_main_menu_mode(cls, value: str | None) -> str:
@@ -928,6 +950,10 @@ class Settings(BaseSettings):
"""Проверяет, используется ли SQLite"""
return 'sqlite' in self.get_database_url()
def get_proxy_url(self) -> str | None:
"""Return SOCKS5 proxy URL or None."""
return self.PROXY_URL if self.PROXY_URL else None
def is_admin(self, telegram_id: int | None = None, email: str | None = None) -> bool:
"""
Check if user is admin by telegram_id or email.
@@ -1439,24 +1465,44 @@ class Settings(BaseSettings):
_CABINET_URL_DEFAULT = 'https://example.com/cabinet'
def _encode_referral_code(self, referral_code: str) -> str:
"""Validate and URL-encode a referral code."""
if not referral_code:
raise ValueError('referral_code must not be empty or None')
return _url_quote(referral_code, safe='')
def _normalized_cabinet_url(self) -> str | None:
"""Return normalized cabinet URL, or None if not configured."""
cabinet_url = (self.CABINET_URL or '').strip().rstrip('/')
if not cabinet_url or cabinet_url == self._CABINET_URL_DEFAULT:
return None
return cabinet_url
def get_referral_link(self, referral_code: str, bot_username: str | None = None) -> str:
"""Build a referral link pointing to the web cabinet.
Falls back to a Telegram bot deep link when CABINET_URL is not configured.
"""
from urllib.parse import quote
cabinet_link = self.get_cabinet_referral_link(referral_code)
if cabinet_link:
return cabinet_link
return self.get_bot_referral_link(referral_code, bot_username)
if not referral_code:
raise ValueError('referral_code must not be empty or None')
safe_code = quote(referral_code, safe='')
cabinet_url = (self.CABINET_URL or '').strip().rstrip('/')
if cabinet_url and cabinet_url != self._CABINET_URL_DEFAULT:
sep = '&' if '?' in cabinet_url else '?'
return f'{cabinet_url}{sep}ref={safe_code}'
def get_bot_referral_link(self, referral_code: str, bot_username: str | None = None) -> str:
"""Always return the Telegram bot deep link for a referral code."""
safe_code = self._encode_referral_code(referral_code)
username = bot_username or self.get_bot_username() or 'bot'
return f'https://t.me/{username}?start={safe_code}'
def get_cabinet_referral_link(self, referral_code: str) -> str | None:
"""Return the cabinet referral link, or None if cabinet is not configured."""
cabinet_url = self._normalized_cabinet_url()
if not cabinet_url:
return None
safe_code = self._encode_referral_code(referral_code)
sep = '&' if '?' in cabinet_url else '?'
return f'{cabinet_url}{sep}ref={safe_code}'
def is_deep_links_enabled(self) -> bool:
return self.ENABLE_DEEP_LINKS
+17 -1
View File
@@ -67,8 +67,24 @@ async def get_cryptobot_payment_by_id(db: AsyncSession, payment_id: int) -> Cryp
return result.scalar_one_or_none()
async def get_cryptobot_payment_by_invoice_id_for_update(db: AsyncSession, invoice_id: str) -> CryptoBotPayment | None:
result = await db.execute(
select(CryptoBotPayment)
.options(selectinload(CryptoBotPayment.user))
.where(CryptoBotPayment.invoice_id == invoice_id)
.with_for_update()
.execution_options(populate_existing=True)
)
return result.scalar_one_or_none()
async def get_cryptobot_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> CryptoBotPayment | None:
result = await db.execute(select(CryptoBotPayment).where(CryptoBotPayment.id == payment_id).with_for_update())
result = await db.execute(
select(CryptoBotPayment)
.where(CryptoBotPayment.id == payment_id)
.with_for_update()
.execution_options(populate_existing=True)
)
return result.scalar_one_or_none()
+6 -1
View File
@@ -63,7 +63,12 @@ async def get_freekassa_payment_by_id(db: AsyncSession, payment_id: int) -> Free
async def get_freekassa_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> FreekassaPayment | None:
result = await db.execute(select(FreekassaPayment).where(FreekassaPayment.id == payment_id).with_for_update())
result = await db.execute(
select(FreekassaPayment)
.where(FreekassaPayment.id == payment_id)
.with_for_update()
.execution_options(populate_existing=True)
)
return result.scalar_one_or_none()
+6 -1
View File
@@ -65,7 +65,12 @@ async def get_kassa_ai_payment_by_id(db: AsyncSession, payment_id: int) -> Kassa
async def get_kassa_ai_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> KassaAiPayment | None:
result = await db.execute(select(KassaAiPayment).where(KassaAiPayment.id == payment_id).with_for_update())
result = await db.execute(
select(KassaAiPayment)
.where(KassaAiPayment.id == payment_id)
.with_for_update()
.execution_options(populate_existing=True)
)
return result.scalar_one_or_none()
+6 -1
View File
@@ -58,7 +58,12 @@ async def get_mulenpay_payment_by_local_id(db: AsyncSession, payment_id: int) ->
async def get_mulenpay_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> MulenPayPayment | None:
result = await db.execute(select(MulenPayPayment).where(MulenPayPayment.id == payment_id).with_for_update())
result = await db.execute(
select(MulenPayPayment)
.where(MulenPayPayment.id == payment_id)
.with_for_update()
.execution_options(populate_existing=True)
)
return result.scalar_one_or_none()
+6 -1
View File
@@ -67,7 +67,12 @@ async def get_pal24_payment_by_id(db: AsyncSession, payment_id: int) -> Pal24Pay
async def get_pal24_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> Pal24Payment | None:
result = await db.execute(select(Pal24Payment).where(Pal24Payment.id == payment_id).with_for_update())
result = await db.execute(
select(Pal24Payment)
.where(Pal24Payment.id == payment_id)
.with_for_update()
.execution_options(populate_existing=True)
)
return result.scalar_one_or_none()
+6 -1
View File
@@ -71,7 +71,12 @@ async def get_platega_payment_by_id(db: AsyncSession, payment_id: int) -> Plateg
async def get_platega_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> PlategaPayment | None:
result = await db.execute(select(PlategaPayment).where(PlategaPayment.id == payment_id).with_for_update())
result = await db.execute(
select(PlategaPayment)
.where(PlategaPayment.id == payment_id)
.with_for_update()
.execution_options(populate_existing=True)
)
return result.scalar_one_or_none()
+6 -19
View File
@@ -38,8 +38,8 @@ _POLICY_UPDATABLE_FIELDS = frozenset(
}
)
# Superadmin level constant
_SUPERADMIN_LEVEL = 999
# Superadmin level constant — single source of truth, imported by admin_roles and bootstrap
SUPERADMIN_LEVEL = 999
class AdminRoleCRUD:
@@ -240,21 +240,6 @@ class UserRoleCRUD:
logger.info('Assigned role to user', user_role_id=user_role.id, user_id=user_id, role_id=role_id)
return user_role
@staticmethod
async def revoke_role(db: AsyncSession, user_role_id: int) -> bool:
"""Soft-revoke: set is_active=False. Returns False if not found."""
result = await db.execute(select(UserRole).where(UserRole.id == user_role_id))
user_role = result.scalar_one_or_none()
if not user_role:
return False
user_role.is_active = False
await db.flush()
logger.info(
'Revoked user role', user_role_id=user_role_id, user_id=user_role.user_id, role_id=user_role.role_id
)
return True
@staticmethod
async def get_all_admins(
db: AsyncSession,
@@ -296,14 +281,16 @@ class UserRoleCRUD:
@staticmethod
async def get_superadmin_count(db: AsyncSession) -> int:
"""Count users with an active role at superadmin level (999)."""
"""Count users with an active, non-expired role at superadmin level (999)."""
now = datetime.now(UTC)
result = await db.execute(
select(func.count(func.distinct(UserRole.user_id)))
.join(AdminRole, UserRole.role_id == AdminRole.id)
.where(
UserRole.is_active.is_(True),
AdminRole.is_active.is_(True),
AdminRole.level == _SUPERADMIN_LEVEL,
AdminRole.level == SUPERADMIN_LEVEL,
or_(UserRole.expires_at.is_(None), UserRole.expires_at > now),
)
)
return result.scalar() or 0
+11
View File
@@ -66,6 +66,17 @@ async def get_riopay_payment_by_id(db: AsyncSession, payment_id: int) -> RioPayP
return result.scalar_one_or_none()
async def get_riopay_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> RioPayPayment | None:
"""Получает платеж по ID с блокировкой FOR UPDATE (для защиты от TOCTOU race)."""
result = await db.execute(
select(RioPayPayment)
.where(RioPayPayment.id == payment_id)
.with_for_update()
.execution_options(populate_existing=True)
)
return result.scalar_one_or_none()
async def update_riopay_payment_status(
db: AsyncSession,
payment: RioPayPayment,
+6 -1
View File
@@ -70,7 +70,12 @@ async def get_severpay_payment_by_id(db: AsyncSession, payment_id: int) -> Sever
async def get_severpay_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> SeverPayPayment | None:
"""Получает платеж по ID с блокировкой FOR UPDATE."""
result = await db.execute(select(SeverPayPayment).where(SeverPayPayment.id == payment_id).with_for_update())
result = await db.execute(
select(SeverPayPayment)
.where(SeverPayPayment.id == payment_id)
.with_for_update()
.execution_options(populate_existing=True)
)
return result.scalar_one_or_none()
+15 -10
View File
@@ -51,6 +51,11 @@ async def create_transaction(
else amount_kopeks
)
# Default payment_method to BALANCE for subscription/gift payments from bot (not landing)
# to avoid double-counting with DEPOSIT in revenue calculations
if payment_method is None and type in (TransactionType.SUBSCRIPTION_PAYMENT, TransactionType.GIFT_PAYMENT):
payment_method = PaymentMethod.BALANCE
transaction = Transaction(
user_id=user_id,
type=type.value,
@@ -278,11 +283,11 @@ async def get_transactions_statistics(
if not end_date:
end_date = datetime.now(UTC)
# Доход считаем только по реальным платежам (исключаем колесо, промокоды, админские пополнения)
# Доход считаем по реальным платежам + прямые покупки подписок (лендинги)
income_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
and_(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]),
Transaction.is_completed == True,
Transaction.created_at >= start_date,
Transaction.created_at <= end_date,
@@ -343,7 +348,7 @@ async def get_transactions_statistics(
)
.where(
and_(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]),
Transaction.is_completed == True,
Transaction.created_at >= start_date,
Transaction.created_at <= end_date,
@@ -363,11 +368,11 @@ async def get_transactions_statistics(
)
transactions_today = today_result.scalar()
# Доход за сегодня - только реальные платежи
# Доход за сегодня реальные платежи + прямые покупки подписок (лендинги)
today_income_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
and_(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]),
Transaction.is_completed == True,
Transaction.created_at >= today,
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
@@ -391,17 +396,17 @@ async def get_transactions_statistics(
async def get_revenue_by_period(db: AsyncSession, days: int = 30) -> list[dict]:
"""Доход по дням - только реальные платежи."""
"""Доход по дням реальные платежи + прямые покупки подписок (лендинги)."""
start_date = datetime.now(UTC) - timedelta(days=days)
result = await db.execute(
select(
func.date(Transaction.created_at).label('date'),
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('amount'),
func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0).label('amount'),
)
.where(
and_(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]),
Transaction.is_completed == True,
Transaction.created_at >= start_date,
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
+6 -1
View File
@@ -72,7 +72,12 @@ async def get_wata_payment_by_id(
async def get_wata_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> WataPayment | None:
result = await db.execute(select(WataPayment).where(WataPayment.id == payment_id).with_for_update())
result = await db.execute(
select(WataPayment)
.where(WataPayment.id == payment_id)
.with_for_update()
.execution_options(populate_existing=True)
)
return result.scalar_one_or_none()
+69 -32
View File
@@ -1,5 +1,6 @@
import hashlib
import json
from html import escape as html_escape
from pathlib import Path
import qrcode
@@ -45,7 +46,8 @@ async def show_referral_info(callback: types.CallbackQuery, db_user: User, db: A
summary = await get_user_referral_summary(db, db_user.id)
bot_username = (await callback.bot.get_me()).username
referral_link = settings.get_referral_link(db_user.referral_code, bot_username)
bot_referral_link = settings.get_bot_referral_link(db_user.referral_code, bot_username)
cabinet_referral_link = settings.get_cabinet_referral_link(db_user.referral_code)
referral_text = (
texts.t('REFERRAL_PROGRAM_TITLE', '👥 <b>Реферальная программа</b>')
@@ -114,13 +116,27 @@ async def show_referral_info(callback: types.CallbackQuery, db_user: User, db: A
'• Комиссия с каждого пополнения реферала: <b>{percent}%</b>',
).format(percent=get_effective_referral_commission_percent(db_user))
referral_text += '\n' + commission_line + '\n\n'
# Show bot link
referral_text += (
texts.t('REFERRAL_BOT_LINK_TITLE', '🤖 <b>Ссылка на бота:</b>')
+ f'\n<code>{html_escape(bot_referral_link)}</code>\n'
)
# Show cabinet link if configured
if cabinet_referral_link:
referral_text += (
'\n'
+ texts.t('REFERRAL_CABINET_LINK_TITLE', '🌐 <b>Ссылка на кабинет:</b>')
+ f'\n<code>{html_escape(cabinet_referral_link)}</code>\n'
)
referral_text += (
'\n'
+ commission_line
+ '\n\n'
+ texts.t('REFERRAL_LINK_TITLE', '🔗 <b>Ваша реферальная ссылка:</b>')
+ f'\n<code>{referral_link}</code>\n\n'
+ texts.t('REFERRAL_CODE_TITLE', '🆔 <b>Ваш код:</b> <code>{code}</code>').format(code=db_user.referral_code)
+ texts.t('REFERRAL_CODE_TITLE', '🆔 <b>Ваш код:</b> <code>{code}</code>').format(
code=html_escape(str(db_user.referral_code or ''))
)
+ '\n\n'
)
@@ -158,7 +174,7 @@ async def show_referral_info(callback: types.CallbackQuery, db_user: User, db: A
).format(
reason=reason_text,
amount=texts.format_price(earning['amount_kopeks']),
referral_name=earning['referral_name'],
referral_name=html_escape(str(earning['referral_name'] or '')),
)
+ '\n'
)
@@ -243,15 +259,15 @@ async def show_referral_qr(
await callback.answer()
bot_username = (await callback.bot.get_me()).username
referral_link = settings.get_referral_link(db_user.referral_code, bot_username)
bot_referral_link = settings.get_bot_referral_link(db_user.referral_code, bot_username)
qr_dir = Path('data') / 'referral_qr'
qr_dir.mkdir(parents=True, exist_ok=True)
link_hash = hashlib.md5(referral_link.encode()).hexdigest()[:8]
link_hash = hashlib.md5(bot_referral_link.encode()).hexdigest()[:8]
file_path = qr_dir / f'{db_user.id}_{link_hash}.png'
if not file_path.exists():
img = qrcode.make(referral_link)
img = qrcode.make(bot_referral_link)
img.save(file_path)
photo = FSInputFile(file_path)
@@ -259,25 +275,28 @@ async def show_referral_qr(
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_referrals')]]
)
caption = texts.t(
'REFERRAL_QR_BOT_LINK',
'🤖 Ссылка на бота:\n{link}',
).format(link=bot_referral_link)
cabinet_referral_link = settings.get_cabinet_referral_link(db_user.referral_code)
if cabinet_referral_link:
caption += '\n\n' + texts.t(
'REFERRAL_QR_CABINET_LINK',
'🌐 Ссылка на кабинет:\n{link}',
).format(link=cabinet_referral_link)
try:
await callback.message.edit_media(
types.InputMediaPhoto(
media=photo,
caption=texts.t(
'REFERRAL_LINK_CAPTION',
'🔗 Ваша реферальная ссылка:\n{link}',
).format(link=referral_link),
),
types.InputMediaPhoto(media=photo, caption=caption),
reply_markup=keyboard,
)
except TelegramBadRequest:
await callback.message.delete()
await callback.message.answer_photo(
photo,
caption=texts.t(
'REFERRAL_LINK_CAPTION',
'🔗 Ваша реферальная ссылка:\n{link}',
).format(link=referral_link),
caption=caption,
reply_markup=keyboard,
)
@@ -322,7 +341,7 @@ async def show_detailed_referral_list(callback: types.CallbackQuery, db_user: Us
texts.t(
'REFERRAL_LIST_ITEM_HEADER',
'{index}. {status} <b>{name}</b>',
).format(index=i, status=status_emoji, name=referral['full_name'])
).format(index=i, status=status_emoji, name=html_escape(str(referral['full_name'] or '')))
+ '\n'
)
text += (
@@ -454,7 +473,7 @@ async def show_referral_analytics(callback: types.CallbackQuery, db_user: User,
'{index}. {name}: {amount} ({count} начислений)',
).format(
index=i,
name=ref['referral_name'],
name=html_escape(str(ref['referral_name'] or '')),
amount=texts.format_price(ref['total_earned_kopeks']),
count=ref['earnings_count'],
)
@@ -485,7 +504,8 @@ async def create_invite_message(callback: types.CallbackQuery, db_user: User):
return
bot_username = (await callback.bot.get_me()).username
referral_link = settings.get_referral_link(db_user.referral_code, bot_username)
bot_referral_link = settings.get_bot_referral_link(db_user.referral_code, bot_username)
cabinet_referral_link = settings.get_cabinet_referral_link(db_user.referral_code)
invite_text = texts.t('REFERRAL_INVITE_TITLE', '🎉 Присоединяйся к VPN сервису!')
@@ -507,14 +527,29 @@ async def create_invite_message(callback: types.CallbackQuery, db_user: User):
+ texts.t('REFERRAL_INVITE_FEATURE_SECURE', '🔒 Надежная защита')
+ '\n\n'
+ texts.t('REFERRAL_INVITE_LINK_PROMPT', '👇 Переходи по ссылке:')
+ f'\n{referral_link}'
+ f'\n{bot_referral_link}'
)
if cabinet_referral_link:
invite_text += (
'\n\n'
+ texts.t('REFERRAL_INVITE_CABINET_LINK', '🌐 Или через личный кабинет:')
+ f'\n{cabinet_referral_link}'
)
# Compact share text for switch_inline_query (256-char limit)
share_text = invite_text
if len(share_text) > 256:
share_text = texts.t('REFERRAL_INVITE_TITLE', '🎉 Присоединяйся к VPN сервису!') + f'\n\n👇 {bot_referral_link}'
if cabinet_referral_link and len(share_text) + len(cabinet_referral_link) + 5 <= 256:
share_text += f'\n🌐 {cabinet_referral_link}'
share_text = share_text[:256]
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t('REFERRAL_SHARE_BUTTON', '📤 Поделиться'), switch_inline_query=invite_text
text=texts.t('REFERRAL_SHARE_BUTTON', '📤 Поделиться'), switch_inline_query=share_text
)
],
[types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_referrals')],
@@ -531,7 +566,7 @@ async def create_invite_message(callback: types.CallbackQuery, db_user: User):
'Нажмите кнопку «📤 Поделиться» чтобы отправить приглашение в любой чат, или скопируйте текст ниже:',
)
+ '\n\n'
f'<code>{invite_text}</code>'
f'<code>{html_escape(invite_text)}</code>'
),
keyboard,
)
@@ -584,7 +619,7 @@ async def show_withdrawal_info(callback: types.CallbackQuery, db_user: User, db:
]
)
else:
text += f'{reason}\n'
text += f'{html_escape(str(reason))}\n'
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_referrals')])
@@ -746,7 +781,7 @@ async def process_payment_details(message: types.Message, db_user: User, db: Asy
)
text += (
texts.t('REFERRAL_WITHDRAWAL_CONFIRM_DETAILS', '💳 Реквизиты:\n<code>{details}</code>').format(
details=payment_details
details=html_escape(payment_details)
)
+ '\n\n'
)
@@ -792,16 +827,18 @@ async def confirm_withdrawal_request(callback: types.CallbackQuery, db_user: Use
# Отправляем уведомление админам
analysis = json.loads(request.risk_analysis) if request.risk_analysis else {}
user_id_display = db_user.telegram_id or db_user.email or f'#{db_user.id}'
user_id_display = html_escape(str(db_user.telegram_id or db_user.email or f'#{db_user.id}'))
safe_name = html_escape(db_user.full_name or 'Без имени')
safe_details = html_escape(payment_details)
admin_text = f"""
🔔 <b>Новая заявка на вывод #{request.id}</b>
👤 Пользователь: {db_user.full_name or 'Без имени'}
👤 Пользователь: {safe_name}
🆔 ID: <code>{user_id_display}</code>
💰 Сумма: <b>{amount_kopeks / 100:.0f}</b>
💳 Реквизиты:
<code>{payment_details}</code>
<code>{safe_details}</code>
{referral_withdrawal_service.format_analysis_for_admin(analysis)}
"""
+5
View File
@@ -1317,6 +1317,11 @@
"REFERRAL_INVITE_TITLE": "🎉 Join the VPN service!",
"REFERRAL_LINK_CAPTION": "🔗 Your referral link:\n{link}",
"REFERRAL_LINK_TITLE": "🔗 <b>Your referral link:</b>",
"REFERRAL_BOT_LINK_TITLE": "🤖 <b>Bot link:</b>",
"REFERRAL_CABINET_LINK_TITLE": "🌐 <b>Cabinet link:</b>",
"REFERRAL_QR_BOT_LINK": "🤖 Bot link:\n{link}",
"REFERRAL_QR_CABINET_LINK": "🌐 Cabinet link:\n{link}",
"REFERRAL_INVITE_CABINET_LINK": "🌐 Or via personal cabinet:",
"REFERRAL_LIST_BUTTON": "👥 Referral list",
"REFERRAL_LIST_EMPTY": "📋 You have no referrals yet.\n\nShare your referral link to start earning!",
"REFERRAL_LIST_HEADER": "👥 <b>Your referrals</b> (page {current}/{total})",
+5
View File
@@ -1338,6 +1338,11 @@
"REFERRAL_INVITE_TITLE": "🎉 به سرویس VPN بپیوند!",
"REFERRAL_LINK_CAPTION": "🔗 لینک دعوت شما:\n{link}",
"REFERRAL_LINK_TITLE": "🔗 <b>لینک دعوت شما:</b>",
"REFERRAL_BOT_LINK_TITLE": "🤖 <b>لینک ربات:</b>",
"REFERRAL_CABINET_LINK_TITLE": "🌐 <b>لینک کابینت:</b>",
"REFERRAL_QR_BOT_LINK": "🤖 لینک ربات:\n{link}",
"REFERRAL_QR_CABINET_LINK": "🌐 لینک کابینت:\n{link}",
"REFERRAL_INVITE_CABINET_LINK": "🌐 یا از طریق کابینت شخصی:",
"REFERRAL_LIST_BUTTON": "👥 لیست دعوت‌شدگان",
"REFERRAL_LIST_EMPTY": "📋 هنوز دعوت‌شده‌ای ندارید.\n\nلینک دعوت خود را به اشتراک بگذارید!",
"REFERRAL_LIST_HEADER": "👥 <b>دعوت‌شدگان شما</b> (صفحه {current}/{total})",
+5
View File
@@ -1338,6 +1338,11 @@
"REFERRAL_INVITE_TITLE": "🎉 Присоединяйся к VPN сервису!",
"REFERRAL_LINK_CAPTION": "🔗 Ваша реферальная ссылка:\n{link}",
"REFERRAL_LINK_TITLE": "🔗 <b>Ваша реферальная ссылка:</b>",
"REFERRAL_BOT_LINK_TITLE": "🤖 <b>Ссылка на бота:</b>",
"REFERRAL_CABINET_LINK_TITLE": "🌐 <b>Ссылка на кабинет:</b>",
"REFERRAL_QR_BOT_LINK": "🤖 Ссылка на бота:\n{link}",
"REFERRAL_QR_CABINET_LINK": "🌐 Ссылка на кабинет:\n{link}",
"REFERRAL_INVITE_CABINET_LINK": "🌐 Или через личный кабинет:",
"REFERRAL_LIST_BUTTON": "👥 Список рефералов",
"REFERRAL_LIST_EMPTY": "📋 У вас пока нет рефералов.\n\nПоделитесь своей реферальной ссылкой, чтобы начать зарабатывать!",
"REFERRAL_LIST_HEADER": "👥 <b>Ваши рефералы</b> (стр. {current}/{total})",
+5
View File
@@ -1254,6 +1254,11 @@
"REFERRAL_INVITE_TITLE": "🎉 Приєднуйся до VPN сервісу!",
"REFERRAL_LINK_CAPTION": "🔗 Ваше реферальне посилання:\n{link}",
"REFERRAL_LINK_TITLE": "🔗 <b>Ваше реферальне посилання:</b>",
"REFERRAL_BOT_LINK_TITLE": "🤖 <b>Посилання на бота:</b>",
"REFERRAL_CABINET_LINK_TITLE": "🌐 <b>Посилання на кабінет:</b>",
"REFERRAL_QR_BOT_LINK": "🤖 Посилання на бота:\n{link}",
"REFERRAL_QR_CABINET_LINK": "🌐 Посилання на кабінет:\n{link}",
"REFERRAL_INVITE_CABINET_LINK": "🌐 Або через особистий кабінет:",
"REFERRAL_LIST_BUTTON": "👥 Список рефералів",
"REFERRAL_LIST_EMPTY": "📋 У вас поки немає рефералів.\n\nПоділіться своїм реферальним посиланням, щоб почати заробляти!",
"REFERRAL_LIST_HEADER": "👥 <b>Ваші реферали</b> (стор. {current}/{total})",
+5
View File
@@ -1252,6 +1252,11 @@
"REFERRAL_INVITE_TITLE": "🎉加入VPN服务!",
"REFERRAL_LINK_CAPTION": "🔗您的推荐链接:\n{link}",
"REFERRAL_LINK_TITLE": "🔗<b>您的推荐链接:</b>",
"REFERRAL_BOT_LINK_TITLE": "🤖<b>机器人链接:</b>",
"REFERRAL_CABINET_LINK_TITLE": "🌐<b>控制面板链接:</b>",
"REFERRAL_QR_BOT_LINK": "🤖机器人链接:\n{link}",
"REFERRAL_QR_CABINET_LINK": "🌐控制面板链接:\n{link}",
"REFERRAL_INVITE_CABINET_LINK": "🌐或通过个人面板:",
"REFERRAL_LIST_BUTTON": "👥推荐列表",
"REFERRAL_LIST_EMPTY": "📋您目前没有推荐。\n\n分享您的推荐链接开始赚钱吧!",
"REFERRAL_LIST_HEADER": "👥<b>您的推荐</b>(第{current}/{total}页)",
+11 -18
View File
@@ -46,11 +46,10 @@ async def _send_admin_notification(
if not getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) or not settings.BOT_TOKEN:
return
try:
from aiogram import Bot
from app.bot_factory import create_bot
from app.services.admin_notification_service import AdminNotificationService
async with Bot(token=settings.BOT_TOKEN) as bot:
async with create_bot() as bot:
service = AdminNotificationService(bot)
await service.send_guest_purchase_notification(
purchase,
@@ -546,9 +545,9 @@ async def _find_or_create_user(
resolved_telegram_id: int | None = pre_resolved_telegram_id
if resolved_telegram_id is None:
try:
from aiogram import Bot
from app.bot_factory import create_bot
async with Bot(token=settings.BOT_TOKEN) as bot:
async with create_bot() as bot:
chat = await asyncio.wait_for(
bot.get_chat(chat_id=f'@{username}'),
timeout=5.0,
@@ -656,11 +655,10 @@ async def _send_telegram_gift_notification(
try:
import html as html_mod
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from app.bot_factory import create_bot
gift_from = ''
if purchase.contact_value:
safe_name = html_mod.escape(purchase.contact_value)
@@ -691,10 +689,7 @@ async def _send_telegram_gift_notification(
]
)
async with Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
) as bot:
async with create_bot() as bot:
await bot.send_message(
chat_id=user.telegram_id,
text=text,
@@ -1205,8 +1200,7 @@ async def _send_stuck_purchase_alert(data: dict, retry_count: int, phase: str) -
try:
import html as html_mod
from aiogram import Bot
from app.bot_factory import create_bot
from app.services.admin_notification_service import AdminNotificationService, NotificationCategory
amount_rub = data['amount_kopeks'] / 100
@@ -1225,7 +1219,7 @@ async def _send_stuck_purchase_alert(data: dict, retry_count: int, phase: str) -
f'Requires manual investigation.'
)
async with Bot(token=settings.BOT_TOKEN) as bot:
async with create_bot() as bot:
service = AdminNotificationService(bot)
await service.send_admin_notification(text, category=NotificationCategory.ERRORS)
except Exception:
@@ -1244,8 +1238,7 @@ async def _send_amount_mismatch_alert(
try:
import html as html_mod
from aiogram import Bot
from app.bot_factory import create_bot
from app.services.admin_notification_service import AdminNotificationService, NotificationCategory
text = (
@@ -1260,7 +1253,7 @@ async def _send_amount_mismatch_alert(
f'Requires manual investigation.'
)
async with Bot(token=settings.BOT_TOKEN) as bot:
async with create_bot() as bot:
service = AdminNotificationService(bot)
await service.send_admin_notification(text, category=NotificationCategory.ERRORS)
except Exception:
+23 -35
View File
@@ -452,18 +452,10 @@ class CloudPaymentsPaymentMixin:
transaction: Any,
) -> None:
"""Send success notification to user via Telegram."""
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from app.config import settings
from app.bot_factory import create_bot
from app.localization.texts import get_texts
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
# Skip email-only users (no telegram_id)
if not user.telegram_id:
logger.debug('Skipping CloudPayments notification for email-only user', user_id=user.id)
@@ -492,15 +484,18 @@ class CloudPaymentsPaymentMixin:
if referrer_info:
message += f'\n\n{referrer_info}'
try:
await bot.send_message(
chat_id=user.telegram_id,
text=message,
parse_mode='HTML',
reply_markup=keyboard,
)
except Exception as error:
logger.warning('Не удалось отправить уведомление пользователю', telegram_id=user.telegram_id, error=error)
async with create_bot() as bot:
try:
await bot.send_message(
chat_id=user.telegram_id,
text=message,
parse_mode='HTML',
reply_markup=keyboard,
)
except Exception as error:
logger.warning(
'Не удалось отправить уведомление пользователю', telegram_id=user.telegram_id, error=error
)
async def _send_cloudpayments_fail_notification(
self,
@@ -508,27 +503,20 @@ class CloudPaymentsPaymentMixin:
message: str,
) -> None:
"""Send failure notification to user via Telegram."""
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from app.config import settings
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
from app.bot_factory import create_bot
text = f'❌ <b>Оплата не прошла</b>\n\n{message}'
try:
await bot.send_message(
chat_id=telegram_id,
text=text,
parse_mode='HTML',
)
except Exception as error:
logger.warning('Не удалось отправить уведомление пользователю', telegram_id=telegram_id, error=error)
async with create_bot() as bot:
try:
await bot.send_message(
chat_id=telegram_id,
text=text,
parse_mode='HTML',
)
except Exception as error:
logger.warning('Не удалось отправить уведомление пользователю', telegram_id=telegram_id, error=error)
async def get_cloudpayments_payment_status(
self,
+16 -12
View File
@@ -151,6 +151,13 @@ class CryptoBotPaymentMixin:
)
return True
# Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race)
locked = await cryptobot_crud.get_cryptobot_payment_by_invoice_id_for_update(db, invoice_id)
if not locked:
logger.error('CryptoBot: не удалось заблокировать платёж', invoice_id=invoice_id)
return False
payment = locked
if payment.status == 'paid':
logger.info('CryptoBot платеж уже обработан', invoice_id=invoice_id)
return True
@@ -164,13 +171,14 @@ class CryptoBotPaymentMixin:
else:
paid_at = datetime.now(UTC)
updated_payment = await cryptobot_crud.update_cryptobot_payment_status(
db,
invoice_id,
status,
paid_at,
commit=False,
)
# Inline field updates — NO intermediate commit that would release FOR UPDATE lock
payment.status = status
payment.updated_at = datetime.now(UTC)
if status == 'paid' and paid_at:
payment.paid_at = paid_at
await db.flush()
updated_payment = payment
descriptor = decode_payment_payload(
getattr(updated_payment, 'payload', '') or '',
@@ -202,11 +210,7 @@ class CryptoBotPaymentMixin:
if renewal_handled:
return True
locked = await cryptobot_crud.get_cryptobot_payment_by_id_for_update(db, updated_payment.id)
if not locked:
logger.error('CryptoBot: не удалось заблокировать платёж', payment_id=updated_payment.id)
return False
updated_payment = locked
# FOR UPDATE lock already acquired above — no need to re-lock
# --- Guest purchase flow (landing page) ---
# CryptoBot stores guest metadata in the payload field (JSON string),
+53 -42
View File
@@ -198,7 +198,14 @@ class FreekassaPaymentMixin:
logger.warning('Freekassa webhook: платеж не найден order_id', order_id=order_id)
return False
# Проверка дублирования
# Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race)
locked = await freekassa_crud.get_freekassa_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('Freekassa webhook: не удалось заблокировать платёж', payment_id=payment.id)
return False
payment = locked
# Re-check is_paid from the locked row
if payment.is_paid:
logger.info('Freekassa webhook: платеж уже обработан order_id', order_id=order_id)
return True
@@ -213,7 +220,7 @@ class FreekassaPaymentMixin:
)
return False
# Обновляем статус платежа
# Inline field updates — NO intermediate commit that would release FOR UPDATE lock
callback_payload = {
'merchant_id': merchant_id,
'amount': amount,
@@ -221,16 +228,15 @@ class FreekassaPaymentMixin:
'intid': intid,
'cur_id': cur_id,
}
payment = await freekassa_crud.update_freekassa_payment_status(
db=db,
payment=payment,
status='success',
is_paid=True,
freekassa_order_id=intid,
payment_system_id=cur_id,
callback_payload=callback_payload,
)
payment.status = 'success'
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.callback_payload = callback_payload
payment.freekassa_order_id = intid
if cur_id is not None:
payment.payment_system_id = cur_id
payment.updated_at = datetime.now(UTC)
await db.flush()
# Финализируем платеж (начисляем баланс, создаем транзакцию)
return await self._finalize_freekassa_payment(db, payment, intid=intid, trigger='webhook')
@@ -250,13 +256,7 @@ class FreekassaPaymentMixin:
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления."""
payment_module = import_module('app.services.payment_service')
freekassa_lock_crud = import_module('app.database.crud.freekassa')
locked = await freekassa_lock_crud.get_freekassa_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('Freekassa: не удалось заблокировать платёж', payment_id=payment.id)
return False
payment = locked
# FOR UPDATE lock already acquired by caller — just check idempotency
if payment.transaction_id:
logger.info(
'Freekassa платеж уже привязан к транзакции (trigger=)', order_id=payment.order_id, trigger=trigger
@@ -506,32 +506,43 @@ class FreekassaPaymentMixin:
if fk_status == 1:
logger.info('Freekassa payment confirmed via API', order_id=payment.order_id)
callback_payload = {
'check_source': 'api',
'fk_order_data': target_order,
}
# Lock payment row before finalization to prevent concurrent double-processing
locked = await freekassa_crud.get_freekassa_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('Freekassa status check: не удалось заблокировать платёж', payment_id=payment.id)
elif locked.is_paid:
# Another concurrent handler already processed — skip
logger.info('Freekassa платеж уже оплачен после блокировки', order_id=locked.order_id)
payment = locked
else:
payment = locked
# ID заказа на стороне FK (fk_order_id или id)
fk_intid = str(target_order.get('fk_order_id') or target_order.get('id'))
callback_payload = {
'check_source': 'api',
'fk_order_data': target_order,
}
# Обновляем статус
payment = await freekassa_crud.update_freekassa_payment_status(
db=db,
payment=payment,
status='success',
is_paid=True,
freekassa_order_id=fk_intid,
payment_system_id=int(target_order.get('curID')) if target_order.get('curID') else None,
callback_payload=callback_payload,
)
# ID заказа на стороне FK (fk_order_id или id)
fk_intid = str(target_order.get('fk_order_id') or target_order.get('id'))
# Финализируем
await self._finalize_freekassa_payment(
db,
payment,
intid=fk_intid,
trigger='api_check',
)
# Inline field updates — NO intermediate commit that would release FOR UPDATE lock
payment.status = 'success'
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.callback_payload = callback_payload
payment.freekassa_order_id = fk_intid
if target_order.get('curID'):
payment.payment_system_id = int(target_order['curID'])
payment.updated_at = datetime.now(UTC)
await db.flush()
# Финализируем
await self._finalize_freekassa_payment(
db,
payment,
intid=fk_intid,
trigger='api_check',
)
except Exception as e:
logger.error('Error checking Freekassa payment status', e=e)
+53 -42
View File
@@ -191,7 +191,14 @@ class KassaAiPaymentMixin:
logger.warning('KassaAI webhook: платеж не найден order_id', order_id=order_id)
return False
# Проверка дублирования
# Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race)
locked = await kassa_ai_crud.get_kassa_ai_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('KassaAI webhook: не удалось заблокировать платёж', payment_id=payment.id)
return False
payment = locked
# Re-check is_paid from the locked row
if payment.is_paid:
logger.info('KassaAI webhook: платеж уже обработан order_id', order_id=order_id)
return True
@@ -206,7 +213,7 @@ class KassaAiPaymentMixin:
)
return False
# Обновляем статус платежа
# Inline field updates — NO intermediate commit that would release FOR UPDATE lock
callback_payload = {
'merchant_id': merchant_id,
'amount': amount,
@@ -214,16 +221,15 @@ class KassaAiPaymentMixin:
'intid': intid,
'cur_id': cur_id,
}
payment = await kassa_ai_crud.update_kassa_ai_payment_status(
db=db,
payment=payment,
status='success',
is_paid=True,
kassa_ai_order_id=intid,
payment_system_id=cur_id,
callback_payload=callback_payload,
)
payment.status = 'success'
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.callback_payload = callback_payload
payment.kassa_ai_order_id = intid
if cur_id is not None:
payment.payment_system_id = cur_id
payment.updated_at = datetime.now(UTC)
await db.flush()
# Финализируем платеж (начисляем баланс, создаем транзакцию)
return await self._finalize_kassa_ai_payment(db, payment, intid=intid, trigger='webhook')
@@ -243,13 +249,7 @@ class KassaAiPaymentMixin:
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления."""
payment_module = import_module('app.services.payment_service')
kassa_ai_lock_crud = import_module('app.database.crud.kassa_ai')
locked = await kassa_ai_lock_crud.get_kassa_ai_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('KassaAI: не удалось заблокировать платёж', payment_id=payment.id)
return False
payment = locked
# FOR UPDATE lock already acquired by caller — just check idempotency
if payment.transaction_id:
logger.info(
'KassaAI платеж уже привязан к транзакции (trigger=)', order_id=payment.order_id, trigger=trigger
@@ -485,32 +485,43 @@ class KassaAiPaymentMixin:
if kai_status == 1:
logger.info('KassaAI payment confirmed via API', order_id=payment.order_id)
callback_payload = {
'check_source': 'api',
'kai_order_data': target_order,
}
# Lock payment row before finalization to prevent concurrent double-processing
locked = await kassa_ai_crud.get_kassa_ai_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('KassaAI status check: не удалось заблокировать платёж', payment_id=payment.id)
elif locked.is_paid:
# Another concurrent handler already processed — skip
logger.info('KassaAI платеж уже оплачен после блокировки', order_id=locked.order_id)
payment = locked
else:
payment = locked
# ID заказа на стороне KassaAI
kai_intid = str(target_order.get('fk_order_id') or target_order.get('id'))
callback_payload = {
'check_source': 'api',
'kai_order_data': target_order,
}
# Обновляем статус
payment = await kassa_ai_crud.update_kassa_ai_payment_status(
db=db,
payment=payment,
status='success',
is_paid=True,
kassa_ai_order_id=kai_intid,
payment_system_id=int(target_order.get('curID')) if target_order.get('curID') else None,
callback_payload=callback_payload,
)
# ID заказа на стороне KassaAI
kai_intid = str(target_order.get('fk_order_id') or target_order.get('id'))
# Финализируем (начисляем баланс)
await self._finalize_kassa_ai_payment(
db,
payment,
intid=kai_intid,
trigger='api_check',
)
# Inline field updates — NO intermediate commit that would release FOR UPDATE lock
payment.status = 'success'
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.callback_payload = callback_payload
payment.kassa_ai_order_id = kai_intid
if target_order.get('curID'):
payment.payment_system_id = int(target_order['curID'])
payment.updated_at = datetime.now(UTC)
await db.flush()
# Финализируем (начисляем баланс)
await self._finalize_kassa_ai_payment(
db,
payment,
intid=kai_intid,
trigger='api_check',
)
except Exception as e:
logger.error('Error checking KassaAI payment status', e=e)
+21 -20
View File
@@ -174,6 +174,14 @@ class MulenPayPaymentMixin:
)
return False
# Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race)
mulenpay_lock_crud = import_module('app.database.crud.mulenpay')
locked = await mulenpay_lock_crud.get_mulenpay_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('MulenPay: не удалось заблокировать платёж', payment_id=payment.id)
return False
payment = locked
metadata = dict(getattr(payment, 'metadata_json', {}) or {})
invoice_message = metadata.get('invoice_message') or {}
@@ -199,11 +207,9 @@ class MulenPayPaymentMixin:
if payment.is_paid:
if invoice_message_removed:
try:
await payment_module.update_mulenpay_payment_metadata(
db,
payment=payment,
metadata=metadata,
)
payment.metadata_json = metadata
payment.updated_at = datetime.now(UTC)
await db.commit()
except Exception as error: # pragma: no cover - diagnostics
logger.warning(
'Не удалось обновить метаданные после удаления счёта',
@@ -217,21 +223,16 @@ class MulenPayPaymentMixin:
return True
if payment_status == 'success':
await payment_module.update_mulenpay_payment_status(
db,
payment=payment,
status='success',
callback_payload=callback_data,
mulen_payment_id=mulen_payment_id_int,
metadata=metadata,
)
mulenpay_lock_crud = import_module('app.database.crud.mulenpay')
locked = await mulenpay_lock_crud.get_mulenpay_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('MulenPay: не удалось заблокировать платёж', payment_id=payment.id)
return False
payment = locked
# Inline field updates — NO intermediate commit that would release FOR UPDATE lock
payment.status = 'success'
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.callback_payload = callback_data
if mulen_payment_id_int is not None and not payment.mulen_payment_id:
payment.mulen_payment_id = mulen_payment_id_int
payment.metadata_json = metadata
payment.updated_at = datetime.now(UTC)
await db.flush()
if payment.transaction_id:
logger.info('Для платежа уже создана транзакция', display_name=display_name, uuid=payment.uuid)
+50 -39
View File
@@ -240,6 +240,14 @@ class Pal24PaymentMixin:
logger.error('Pal24 платеж не найден: /', bill_id=bill_id, order_id=order_id)
return False
# Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race)
pal24_lock_crud = import_module('app.database.crud.pal24')
locked = await pal24_lock_crud.get_pal24_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('Pal24: не удалось заблокировать платёж', payment_id=payment.id)
return False
payment = locked
if payment.is_paid:
logger.info('Pal24 платеж уже обработан', bill_id=payment.bill_id)
return True
@@ -249,25 +257,32 @@ class Pal24PaymentMixin:
if not isinstance(metadata, dict):
metadata = {}
payment = await payment_module.update_pal24_payment_status(
db,
payment,
status=status,
is_paid=True,
paid_at=datetime.now(UTC),
callback_payload=callback,
payment_id=payment_id,
payment_status=callback.get('Status') or status,
payment_method=(
callback.get('payment_method')
or callback.get('PaymentMethod')
or metadata.get('selected_method')
or getattr(payment, 'payment_method', None)
),
balance_amount=callback.get('BalanceAmount') or callback.get('balance_amount'),
balance_currency=callback.get('BalanceCurrency') or callback.get('balance_currency'),
payer_account=callback.get('AccountNumber') or callback.get('account') or callback.get('Account'),
# Inline field updates — NO intermediate commit that would release FOR UPDATE lock
payment.status = status
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.callback_payload = callback
if payment_id is not None:
payment.payment_id = payment_id
payment.payment_status = callback.get('Status') or status
payment.payment_method = (
callback.get('payment_method')
or callback.get('PaymentMethod')
or metadata.get('selected_method')
or getattr(payment, 'payment_method', None)
)
balance_amount = callback.get('BalanceAmount') or callback.get('balance_amount')
if balance_amount is not None:
payment.balance_amount = balance_amount
balance_currency = callback.get('BalanceCurrency') or callback.get('balance_currency')
if balance_currency is not None:
payment.balance_currency = balance_currency
payer_account = callback.get('AccountNumber') or callback.get('account') or callback.get('Account')
if payer_account is not None:
payment.payer_account = payer_account
payment.last_status = status
payment.updated_at = datetime.now(UTC)
await db.flush()
return await self._finalize_pal24_payment(
db,
@@ -336,23 +351,13 @@ class Pal24PaymentMixin:
if invoice_message_removed:
try:
await payment_module.update_pal24_payment_status(
db,
payment,
status=payment.status,
metadata=metadata,
)
payment.metadata_json = metadata
payment.updated_at = datetime.now(UTC)
await db.flush()
except Exception as error: # pragma: no cover - diagnostics
logger.warning('Не удалось обновить метаданные PayPalych после удаления счёта', error=error)
pal24_lock_crud = import_module('app.database.crud.pal24')
locked = await pal24_lock_crud.get_pal24_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('Pal24: не удалось заблокировать платёж', payment_id=payment.id)
return False
payment = locked
# FOR UPDATE lock already acquired by caller — just check idempotency
if payment.transaction_id:
logger.info('Pal24 платеж уже привязан к транзакции (trigger=)', bill_id=payment.bill_id, trigger=trigger)
return True
@@ -643,14 +648,20 @@ class Pal24PaymentMixin:
if payment.is_paid and not payment.transaction_id:
try:
finalized = await self._finalize_pal24_payment(
db,
payment,
payment_id=getattr(payment, 'payment_id', None),
trigger='status_check',
)
if finalized:
payment = await payment_module.get_pal24_payment_by_id(db, local_payment_id)
# Acquire FOR UPDATE lock before finalization (status_check path)
pal24_lock_crud = import_module('app.database.crud.pal24')
locked = await pal24_lock_crud.get_pal24_payment_by_id_for_update(db, payment.id)
if locked:
payment = locked
if not payment.transaction_id:
finalized = await self._finalize_pal24_payment(
db,
payment,
payment_id=getattr(payment, 'payment_id', None),
trigger='status_check',
)
if finalized:
payment = await payment_module.get_pal24_payment_by_id(db, local_payment_id)
except Exception as error:
logger.error('Ошибка автоматического начисления по Pal24 статусу', error=error, exc_info=True)
+61 -49
View File
@@ -153,35 +153,38 @@ class PlategaPaymentMixin:
logger.warning('Platega webhook: платеж не найден (id=)', transaction_id=transaction_id)
return False
# Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race)
platega_crud = import_module('app.database.crud.platega')
locked = await platega_crud.get_platega_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('Platega: не удалось заблокировать платёж', payment_id=payment.id)
return False
payment = locked
status_raw = str(payload.get('status') or '').upper()
if not status_raw:
logger.warning('Platega webhook без статуса для платежа', payment_id=payment.id)
return False
update_kwargs = {
'status': status_raw,
'callback_payload': payload,
}
if transaction_id:
update_kwargs['platega_transaction_id'] = transaction_id
if status_raw in self._SUCCESS_STATUSES:
if payment.is_paid:
logger.info('Platega платеж уже помечен как оплачен', correlation_id=payment.correlation_id)
await payment_module.update_platega_payment(
db,
payment=payment,
**update_kwargs,
is_paid=True,
)
# Update callback payload without releasing the lock prematurely
payment.callback_payload = payload
if transaction_id and not payment.platega_transaction_id:
payment.platega_transaction_id = transaction_id
payment.updated_at = datetime.now(UTC)
await db.commit()
return True
payment = await payment_module.update_platega_payment(
db,
payment=payment,
**update_kwargs,
)
# Inline field updates — NO intermediate commit that would release FOR UPDATE lock
payment.status = status_raw
payment.callback_payload = payload
if transaction_id and not payment.platega_transaction_id:
payment.platega_transaction_id = transaction_id
payment.updated_at = datetime.now(UTC)
await db.flush()
result = await self._finalize_platega_payment(db, payment, payload)
if result is None:
logger.error('Platega webhook: финализация не удалась', payment_id=payment.id)
@@ -192,7 +195,9 @@ class PlategaPaymentMixin:
await payment_module.update_platega_payment(
db,
payment=payment,
**update_kwargs,
status=status_raw,
callback_payload=payload,
platega_transaction_id=transaction_id or None,
is_paid=False,
)
logger.info('Platega платеж перешёл в статус', correlation_id=payment.correlation_id, status_raw=status_raw)
@@ -201,7 +206,9 @@ class PlategaPaymentMixin:
await payment_module.update_platega_payment(
db,
payment=payment,
**update_kwargs,
status=status_raw,
callback_payload=payload,
platega_transaction_id=transaction_id or None,
)
return True
@@ -231,28 +238,40 @@ class PlategaPaymentMixin:
if remote_payload:
remote_status = str(remote_payload.get('status') or '').upper()
if remote_status and remote_status != payment.status:
await payment_module.update_platega_payment(
db,
payment=payment,
status=remote_status,
metadata={
**(getattr(payment, 'metadata_json', {}) or {}),
'remote_status': remote_payload,
},
)
payment = await payment_module.get_platega_payment_by_id(db, local_payment_id)
status_changed = remote_status and remote_status != payment.status
if remote_status in self._SUCCESS_STATUSES and not payment.is_paid:
payment = await payment_module.update_platega_payment(
db,
payment=payment,
status=remote_status,
callback_payload=remote_payload,
)
result = await self._finalize_platega_payment(db, payment, remote_payload)
if result is not None:
payment = result
# Lock payment row before finalization to prevent concurrent double-processing
platega_crud = import_module('app.database.crud.platega')
locked = await platega_crud.get_platega_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('Platega status check: не удалось заблокировать платёж', payment_id=payment.id)
elif locked.is_paid:
# Another concurrent handler already processed — skip
logger.info('Platega платеж уже оплачен после блокировки', correlation_id=locked.correlation_id)
payment = locked
else:
payment = locked
payment.status = remote_status
payment.callback_payload = remote_payload
payment.metadata_json = {
**(getattr(payment, 'metadata_json', {}) or {}),
'remote_status': remote_payload,
}
payment.updated_at = datetime.now(UTC)
await db.flush()
result = await self._finalize_platega_payment(db, payment, remote_payload)
if result is not None:
payment = result
elif status_changed:
# Non-success status change — safe to persist without lock
payment.status = remote_status
payment.metadata_json = {
**(getattr(payment, 'metadata_json', {}) or {}),
'remote_status': remote_payload,
}
payment.updated_at = datetime.now(UTC)
await db.commit()
return {
'payment': payment,
@@ -279,14 +298,7 @@ class PlategaPaymentMixin:
except ValueError:
paid_at = None
# Lock FIRST, then read fresh state
platega_lock_crud = import_module('app.database.crud.platega')
locked = await platega_lock_crud.get_platega_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('Platega: не удалось заблокировать платёж', payment_id=payment.id)
return None
payment = locked
# FOR UPDATE lock already acquired by caller — just check idempotency
if payment.transaction_id:
logger.info(
'Platega платеж уже связан с транзакцией',
+93 -42
View File
@@ -12,6 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.riopay import (
create_riopay_payment as crud_create_riopay_payment,
get_riopay_payment_by_id_for_update,
get_riopay_payment_by_order_id,
get_riopay_payment_by_riopay_order_id,
update_riopay_payment_status,
@@ -202,7 +203,14 @@ class RioPayPaymentMixin:
)
return False
# Проверка дублирования
# Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race)
locked = await get_riopay_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('RioPay: не удалось заблокировать платёж', payment_id=payment.id)
return False
payment = locked
# Re-check is_paid from the locked row
if payment.is_paid:
logger.info('RioPay webhook: платеж уже обработан', order_id=payment.order_id)
return True
@@ -241,23 +249,33 @@ class RioPayPaymentMixin:
)
return False
# Обновляем статус платежа только после проверки суммы
payment = await update_riopay_payment_status(
db=db,
payment=payment,
status=internal_status,
is_paid=is_paid,
riopay_order_id=riopay_order_id,
payment_method=payload.get('paymentType'),
callback_payload=callback_payload,
)
# Финализируем платеж если оплачен
if is_paid:
# Inline field updates — NO intermediate commit that would release FOR UPDATE lock
payment.status = internal_status
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.updated_at = datetime.now(UTC)
if riopay_order_id:
payment.riopay_order_id = riopay_order_id
if payload.get('paymentType') is not None:
payment.payment_method = payload.get('paymentType')
payment.callback_payload = callback_payload
await db.flush()
return await self._finalize_riopay_payment(
db, payment, riopay_order_id=riopay_order_id, trigger='webhook'
)
# Non-success status — safe to use update with commit
await update_riopay_payment_status(
db=db,
payment=payment,
status=internal_status,
is_paid=False,
riopay_order_id=riopay_order_id,
payment_method=payload.get('paymentType'),
callback_payload=callback_payload,
)
return True
except Exception as e:
@@ -272,7 +290,10 @@ class RioPayPaymentMixin:
riopay_order_id: str | None,
trigger: str,
) -> bool:
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления."""
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления.
FOR UPDATE lock already acquired by caller do NOT acquire again here.
"""
if payment.transaction_id:
logger.info('RioPay платеж уже привязан к транзакции', order_id=payment.order_id, trigger=trigger)
return True
@@ -302,7 +323,7 @@ class RioPayPaymentMixin:
)
return False
# Создаем транзакцию
# Создаем транзакцию (commit=False to keep FOR UPDATE lock intact)
transaction = await create_transaction(
db,
user_id=payment.user_id,
@@ -313,15 +334,13 @@ class RioPayPaymentMixin:
external_id=str(riopay_order_id) if riopay_order_id else payment.order_id,
is_completed=True,
created_at=getattr(payment, 'created_at', None),
commit=False,
)
# Связываем платеж с транзакцией
await update_riopay_payment_status(
db=db,
payment=payment,
status=payment.status,
transaction_id=transaction.id,
)
# Связываем платеж с транзакцией (inline — no commit to preserve lock)
payment.transaction_id = transaction.id
payment.updated_at = datetime.now(UTC)
await db.flush()
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
@@ -344,6 +363,22 @@ class RioPayPaymentMixin:
await db.commit()
# Emit deferred side-effects after atomic commit (events, promo group checks)
try:
from app.database.crud.transaction import emit_transaction_side_effects
await emit_transaction_side_effects(
db,
transaction,
amount_kopeks=payment.amount_kopeks,
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
payment_method=PaymentMethod.RIOPAY,
external_id=str(riopay_order_id) if riopay_order_id else payment.order_id,
)
except Exception as error:
logger.error('Ошибка emit_transaction_side_effects RioPay', error=error)
# Обработка реферального пополнения
try:
from app.services.referral_service import process_referral_topup
@@ -478,29 +513,45 @@ class RioPayPaymentMixin:
'is_paid': False,
}
logger.info('RioPay payment confirmed via API', order_id=payment.order_id)
# Lock payment row before finalization (TOCTOU race protection)
locked = await get_riopay_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error(
'RioPay status check: не удалось заблокировать платёж',
payment_id=payment.id,
)
elif locked.is_paid:
# Another concurrent handler already processed — skip
logger.info(
'RioPay платеж уже оплачен после блокировки',
order_id=locked.order_id,
)
payment = locked
else:
payment = locked
logger.info('RioPay payment confirmed via API', order_id=payment.order_id)
callback_payload = {
'check_source': 'api',
'riopay_order_data': order_data,
}
callback_payload = {
'check_source': 'api',
'riopay_order_data': order_data,
}
payment = await update_riopay_payment_status(
db=db,
payment=payment,
status='success',
is_paid=True,
riopay_order_id=payment.riopay_order_id,
payment_method=order_data.get('paymentType'),
callback_payload=callback_payload,
)
# Inline field updates — NO intermediate commit
payment.status = 'success'
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.updated_at = datetime.now(UTC)
if order_data.get('paymentType') is not None:
payment.payment_method = order_data.get('paymentType')
payment.callback_payload = callback_payload
await db.flush()
await self._finalize_riopay_payment(
db,
payment,
riopay_order_id=payment.riopay_order_id,
trigger='api_check',
)
await self._finalize_riopay_payment(
db,
payment,
riopay_order_id=payment.riopay_order_id,
trigger='api_check',
)
elif internal_status != payment.status:
# Обновляем статус если изменился
payment = await update_riopay_payment_status(
+40 -24
View File
@@ -217,7 +217,14 @@ class SeverPayPaymentMixin:
)
return False
# Проверка дублирования
# Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race)
locked = await severpay_crud.get_severpay_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('SeverPay: не удалось заблокировать платёж', payment_id=payment.id)
return False
payment = locked
# Проверка дублирования (re-check from locked row)
if payment.is_paid:
logger.info('SeverPay webhook: платеж уже обработан', order_id=payment.order_id)
return True
@@ -259,9 +266,11 @@ class SeverPayPaymentMixin:
# Inline field assignments to keep FOR UPDATE lock intact
payment.status = internal_status
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.severpay_id = severpay_id or payment.severpay_id
payment.callback_payload = callback_payload
payment.updated_at = datetime.now(UTC)
await db.flush()
return await self._finalize_severpay_payment(db, payment, severpay_id=severpay_id, trigger='webhook')
# Для не-success статусов можно безопасно коммитить
@@ -290,18 +299,12 @@ class SeverPayPaymentMixin:
) -> bool:
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления.
Использует FOR UPDATE lock для защиты от race condition.
FOR UPDATE lock must be acquired by the caller before invoking this method.
"""
payment_module = import_module('app.services.payment_service')
severpay_crud = import_module('app.database.crud.severpay')
# Lock FIRST, then read fresh state
locked = await severpay_crud.get_severpay_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('SeverPay: не удалось заблокировать платёж', payment_id=payment.id)
return False
payment = locked
# FOR UPDATE lock already acquired by caller — just check idempotency
if payment.transaction_id:
logger.info(
'SeverPay платеж уже связан с транзакцией',
@@ -327,11 +330,12 @@ class SeverPayPaymentMixin:
if guest_result is not None:
return True
# Inline field assignments to keep FOR UPDATE lock
payment.status = 'success'
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.updated_at = datetime.now(UTC)
# Ensure paid fields are set (idempotent — caller may have already set them)
if not payment.is_paid:
payment.status = 'success'
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.updated_at = datetime.now(UTC)
balance_already_credited = bool(metadata.get('balance_credited'))
@@ -559,21 +563,33 @@ class SeverPayPaymentMixin:
'is_paid': False,
}
# Acquire FOR UPDATE lock before finalization
locked = await severpay_crud.get_severpay_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('SeverPay: не удалось заблокировать платёж', payment_id=payment.id)
return None
payment = locked
if payment.is_paid:
logger.info('SeverPay платеж уже обработан (api_check)', order_id=payment.order_id)
return {
'payment': payment,
'status': 'success',
'is_paid': True,
}
logger.info('SeverPay payment confirmed via API', order_id=payment.order_id)
callback_payload = {
# Inline field updates — NO intermediate commit that would release FOR UPDATE lock
payment.status = 'success'
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.callback_payload = {
'check_source': 'api',
'severpay_order_data': order_data,
}
payment = await severpay_crud.update_severpay_payment_status(
db=db,
payment=payment,
status='success',
is_paid=True,
severpay_id=payment.severpay_id,
callback_payload=callback_payload,
)
payment.updated_at = datetime.now(UTC)
await db.flush()
await self._finalize_severpay_payment(
db,
+63 -39
View File
@@ -223,6 +223,14 @@ class WataPaymentMixin:
)
return False
# Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race)
wata_crud = import_module('app.database.crud.wata')
locked = await wata_crud.get_wata_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('WATA: не удалось заблокировать платёж', payment_id=payment.id)
return False
payment = locked
status_lower = transaction_status.lower()
metadata = dict(getattr(payment, 'metadata_json', {}) or {})
metadata['last_webhook'] = payload
@@ -230,17 +238,38 @@ class WataPaymentMixin:
payload.get('terminalPublicId') or payload.get('terminal_public_id') or payload.get('terminalPublicID')
)
if status_lower == 'paid':
if payment.is_paid:
logger.info('WATA платеж уже помечен как оплачен', payment_link_id=payment.payment_link_id)
# Update callback payload without releasing the lock prematurely
payment.callback_payload = payload
payment.metadata_json = metadata
if terminal_public_id:
payment.terminal_public_id = terminal_public_id
await db.commit()
return True
# Inline field updates — NO intermediate commit that would release FOR UPDATE lock
payment.status = transaction_status
payment.last_status = transaction_status
payment.callback_payload = payload
payment.metadata_json = metadata
if terminal_public_id:
payment.terminal_public_id = terminal_public_id
await db.flush()
await self._finalize_wata_payment(db, payment, payload)
return True
# Non-success statuses: safe to use update_wata_payment_status (commits)
update_kwargs: dict[str, Any] = {
'metadata': metadata,
'callback_payload': payload,
'terminal_public_id': terminal_public_id,
'status': transaction_status,
'last_status': transaction_status,
}
if transaction_status:
update_kwargs['status'] = transaction_status
update_kwargs['last_status'] = transaction_status
if status_lower != 'paid' and not payment.is_paid:
if not payment.is_paid:
update_kwargs['is_paid'] = False
payment = await payment_module.update_wata_payment_status(
@@ -249,14 +278,6 @@ class WataPaymentMixin:
**update_kwargs,
)
if status_lower == 'paid':
if payment.is_paid:
logger.info('WATA платеж уже помечен как оплачен', payment_link_id=payment.payment_link_id)
return True
await self._finalize_wata_payment(db, payment, payload)
return True
if status_lower == 'declined':
logger.info('WATA платеж отклонён', payment_link_id=payment.payment_link_id)
@@ -364,7 +385,18 @@ class WataPaymentMixin:
if raw_status:
normalized_status = str(raw_status).lower()
if normalized_status == 'paid':
payment = await self._finalize_wata_payment(db, payment, transaction_payload)
# Lock payment row before finalization to prevent concurrent double-processing
wata_crud = import_module('app.database.crud.wata')
locked = await wata_crud.get_wata_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('WATA status check: не удалось заблокировать платёж', payment_id=payment.id)
elif locked.is_paid:
# Another concurrent handler already processed — skip
logger.info('WATA платеж уже оплачен после блокировки', payment_link_id=locked.payment_link_id)
payment = locked
else:
payment = locked
payment = await self._finalize_wata_payment(db, payment, transaction_payload)
else:
logger.debug(
'WATA транзакция в статусе , повторная обработка не требуется',
@@ -399,6 +431,15 @@ class WataPaymentMixin:
paid_status=paid_status,
)
# FOR UPDATE lock already acquired by caller — just check idempotency
if payment.transaction_id:
logger.info(
'WATA платеж уже привязан к транзакции',
payment_link_id=payment.payment_link_id,
transaction_id=payment.transaction_id,
)
return payment
paid_at = None
if isinstance(transaction_payload, dict):
paid_at = WataService._parse_datetime(transaction_payload.get('paymentTime'))
@@ -420,30 +461,13 @@ class WataPaymentMixin:
existing_metadata['transaction'] = transaction_payload
await payment_module.update_wata_payment_status(
db,
payment=payment,
status='Paid',
is_paid=True,
paid_at=paid_at,
callback_payload=transaction_payload,
metadata=existing_metadata,
)
wata_lock_crud = import_module('app.database.crud.wata')
locked = await wata_lock_crud.get_wata_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('WATA: не удалось заблокировать платёж', payment_id=payment.id)
return None
payment = locked
if payment.transaction_id:
logger.info(
'WATA платеж уже привязан к транзакции',
payment_link_id=payment.payment_link_id,
transaction_id=payment.transaction_id,
)
return payment
# Inline field updates — NO intermediate commit that would release FOR UPDATE lock
payment.status = 'Paid'
payment.is_paid = True
payment.paid_at = paid_at
payment.callback_payload = transaction_payload
payment.metadata_json = existing_metadata
await db.flush()
# --- Guest purchase flow (landing page) ---
wata_metadata = dict(getattr(payment, 'metadata_json', {}) or {})
+1 -4
View File
@@ -15,16 +15,13 @@ import structlog
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.rbac import AccessPolicyCRUD, AuditLogCRUD, UserRoleCRUD
from app.database.crud.rbac import SUPERADMIN_LEVEL, AccessPolicyCRUD, AuditLogCRUD, UserRoleCRUD
if TYPE_CHECKING:
from app.database.models import AccessPolicy, User
SUPERADMIN_LEVEL = 999
logger = structlog.get_logger(__name__)
+2 -3
View File
@@ -1,11 +1,10 @@
from datetime import UTC, datetime
import structlog
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.bot_factory import create_bot
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
@@ -31,7 +30,7 @@ async def _notify_admins_about_auto_assignment(
logger.debug('BOT_TOKEN не настроен — пропускаем уведомление о промогруппе')
return
bot = Bot(token=bot_token, default=DefaultBotProperties(parse_mode='HTML'))
bot = create_bot(token=bot_token)
try:
notification_service = AdminNotificationService(bot)
reason = (
+42 -20
View File
@@ -5,7 +5,6 @@ Auto-assigns the Superadmin role to users listed in ADMIN_IDS / ADMIN_EMAILS
config on bot startup. Runs once during the startup sequence.
"""
from datetime import UTC, datetime
from typing import Final
import structlog
@@ -13,6 +12,7 @@ from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.rbac import SUPERADMIN_LEVEL, UserRoleCRUD
from app.database.models import AdminRole, User, UserRole
@@ -137,7 +137,7 @@ async def _ensure_preset_roles(db: AsyncSession) -> AdminRole | None:
existing = result.scalars().first()
if existing is not None:
if existing.level == 999: # Superadmin level
if existing.level == SUPERADMIN_LEVEL:
superadmin_role = existing
# Добавить НОВЫЕ permissions из кода, не трогая существующие (админ мог кастомизировать)
if existing.is_system:
@@ -196,6 +196,8 @@ async def bootstrap_superadmins(db: AsyncSession) -> None:
if not admin_ids and not admin_emails:
logger.debug('No admin IDs or emails configured, skipping superadmin assignment')
await db.commit()
# Safety check even when no IDs configured — someone may have cleared them
await _warn_if_no_superadmins(db, admin_ids, admin_emails)
return
role_id: int = superadmin_role.id
@@ -225,11 +227,34 @@ async def bootstrap_superadmins(db: AsyncSession) -> None:
else:
logger.debug('Superadmin bootstrap: no new assignments needed')
# ── 5. Safety: warn if no active superadmins exist ────────────
await _warn_if_no_superadmins(db, admin_ids, admin_emails)
except Exception:
await db.rollback()
logger.exception('Failed to bootstrap superadmins, continuing startup')
async def _warn_if_no_superadmins(
db: AsyncSession,
admin_ids: list[int],
admin_emails: list[str],
) -> None:
"""Log critical/warning if no active superadmin RBAC roles exist in DB."""
active = await UserRoleCRUD.get_superadmin_count(db)
if active > 0:
return
if not admin_ids and not admin_emails:
logger.critical(
'No active superadmins exist and no ADMIN_IDS/ADMIN_EMAILS configured. '
'Cabinet admin access is not possible until this is resolved.',
)
else:
logger.warning(
'No active superadmin RBAC roles in DB. Legacy config admins (ADMIN_IDS/ADMIN_EMAILS) still have access.',
)
async def _ensure_role_by_telegram_id(
db: AsyncSession,
*,
@@ -277,14 +302,14 @@ async def _assign_if_missing(
role_id: int,
identifier: str,
) -> bool:
"""Create or reactivate a UserRole row for this user/role pair.
"""Create a UserRole row if none exists for this user/role pair.
Handles the unique constraint on (user_id, role_id) by checking for
ANY existing assignment (active or inactive) and reactivating if needed.
If an assignment already exists (active or revoked), it is left as-is.
This ensures that an admin-revoked role is NOT silently reactivated
on every bot restart.
Returns True if a new assignment was created or an inactive one was reactivated.
Returns True only if a brand-new assignment was created.
"""
# Check for ANY existing assignment (active or not) to respect unique constraint
result = await db.execute(
select(UserRole).where(
UserRole.user_id == user_id,
@@ -300,19 +325,16 @@ async def _assign_if_missing(
user_id=user_id,
identifier=identifier,
)
return False
# Reactivate previously revoked assignment
existing.is_active = True
existing.assigned_at = datetime.now(UTC)
await db.flush()
logger.info(
'Reactivated Superadmin role for user',
user_id=user_id,
role_id=role_id,
identifier=identifier,
user_role_id=existing.id,
)
return True
else:
logger.info(
'Superadmin role was previously revoked, not reactivating '
'(remove user from ADMIN_IDS to stop this warning, '
'or re-assign via cabinet)',
user_id=user_id,
identifier=identifier,
user_role_id=existing.id,
)
return False
user_role = UserRole(
user_id=user_id,
@@ -114,6 +114,8 @@ _ADMIN_ERROR_EVENTS: dict[str, str] = {
'errors.bandwidth_usage_threshold_reached_max_notifications': '⚠️ Достигнут лимит уведомлений о трафике',
}
_ADMIN_NODE_CONNECTION_EVENTS = frozenset({'node.connection_lost', 'node.connection_restored'})
class RemnaWaveWebhookService:
"""Processes incoming webhooks from RemnaWave backend."""
@@ -216,6 +218,10 @@ class RemnaWaveWebhookService:
async def _process_admin_event(self, event_name: str, data: dict) -> bool:
"""Format and send admin notification for infrastructure events."""
if event_name in _ADMIN_NODE_CONNECTION_EVENTS and not settings.REMNAWAVE_WEBHOOK_NOTIFY_NODE_CONNECTION_STATUS:
logger.debug('RemnaWave node connection notifications disabled, skipping event', event_name=event_name)
return True
if not self._admin_service.is_enabled:
logger.debug('Admin notifications disabled, skipping event', event_name=event_name)
return True
@@ -22,7 +22,7 @@ from app.database.crud.subscription_conversion import (
)
from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance
from app.database.models import ServerSquad, Subscription, SubscriptionStatus, TransactionType, User
from app.database.models import PaymentMethod, ServerSquad, Subscription, SubscriptionStatus, TransactionType, User
from app.localization.texts import get_texts
from app.services.subscription_service import SubscriptionService
from app.utils.pricing_utils import (
@@ -1109,6 +1109,7 @@ class MiniAppSubscriptionPurchaseService:
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=pricing.final_total,
description=f'Подписка на {pricing.selection.period.days} дней ({pricing.months} мес)',
payment_method=PaymentMethod.BALANCE,
)
await db.refresh(user)
+2 -1
View File
@@ -12,6 +12,7 @@ import structlog
from aiogram import Bot
from sqlalchemy.ext.asyncio import AsyncSession
from app.bot_factory import create_bot
from app.config import settings
from app.database.crud.subscription import (
add_subscription_servers,
@@ -337,7 +338,7 @@ async def with_admin_notification_service(
bot: Bot | None = None
try:
bot = Bot(token=settings.BOT_TOKEN)
bot = create_bot()
service = AdminNotificationService(bot)
await handler(service)
except Exception as error: # pragma: no cover - defensive logging
+13
View File
@@ -218,6 +218,7 @@ class BotConfigurationService:
'BOT_USERNAME': 'CORE',
'DEFAULT_LANGUAGE': 'LOCALIZATION',
'AVAILABLE_LANGUAGES': 'LOCALIZATION',
'REMNAWAVE_WEBHOOK_NOTIFY_NODE_CONNECTION_STATUS': 'ADMIN_NOTIFICATIONS',
'LANGUAGE_SELECTION_ENABLED': 'LOCALIZATION',
'DEFAULT_DEVICE_LIMIT': 'SUBSCRIPTIONS_CORE',
'DEFAULT_TRAFFIC_LIMIT_GB': 'SUBSCRIPTIONS_CORE',
@@ -843,6 +844,18 @@ class BotConfigurationService:
'example': '60',
'warning': 'Защита от спама уведомлениями по одному и тому же пользователю.',
},
'REMNAWAVE_WEBHOOK_NOTIFY_NODE_CONNECTION_STATUS': {
'description': (
'Уведомления администраторам о потере и восстановлении соединения с нодами из webhook-ов RemnaWave.'
),
'format': 'Булево значение.',
'example': 'false',
'warning': (
'Отключает только события node.connection_lost и node.connection_restored. '
'Остальные инфраструктурные уведомления продолжают отправляться.'
),
'dependencies': 'REMNAWAVE_WEBHOOK_ENABLED, ADMIN_NOTIFICATIONS_ENABLED',
},
'WEBHOOK_NOTIFY_USER_ENABLED': {
'description': (
'Глобальный переключатель уведомлений пользователям от вебхуков RemnaWave. '
+3
View File
@@ -293,6 +293,9 @@ async def _edit_with_photo(self: Message, text: str, **kwargs):
if 'MESSAGE_ID_INVALID' in str(error) or 'message to edit not found' in str(error).lower():
# Сообщение удалено или недоступно — просто игнорируем
return None
if 'message is not modified' in str(error).lower():
# Контент не изменился — безопасно игнорируем
return None
raise
+3 -11
View File
@@ -4,9 +4,6 @@ import mimetypes
from typing import Any
import structlog
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram.types import BufferedInputFile
from fastapi import (
APIRouter,
@@ -20,6 +17,7 @@ from fastapi import (
status,
)
from app.bot_factory import create_bot
from app.config import settings
from ..dependencies import require_api_token
@@ -72,10 +70,7 @@ async def upload_media(
target_chat_id = _resolve_target_chat_id()
upload = BufferedInputFile(file_bytes, filename=file.filename or 'upload')
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
bot = create_bot()
try:
if media_type_normalized == 'photo':
@@ -121,10 +116,7 @@ async def download_media(
file_id: str,
_: Any = Security(require_api_token),
) -> Response:
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
bot = create_bot()
try:
file = await bot.get_file(file_id)
+7 -4
View File
@@ -10,13 +10,13 @@ from typing import Any
from uuid import uuid4
import structlog
from aiogram import Bot
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import ValidationError
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.bot_factory import create_bot
from app.config import settings
from app.database.crud.discount_offer import (
get_latest_claimed_offer_for_user,
@@ -928,7 +928,7 @@ async def create_payment_link(
detail='Failed to prepare Stars payment',
) from exc
bot = Bot(token=settings.BOT_TOKEN)
bot = create_bot()
invoice_payload = _build_balance_invoice_payload(user.id, amount_kopeks)
try:
payment_service = PaymentService(bot)
@@ -1399,7 +1399,7 @@ async def create_payment_link(
if not settings.BOT_TOKEN:
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Bot token is not configured')
bot = Bot(token=settings.BOT_TOKEN)
bot = create_bot()
try:
tribute_service = TributeService(bot)
payment_url = await tribute_service.create_payment_link(
@@ -2869,8 +2869,10 @@ async def _build_referral_info(
referral_settings = settings.get_referral_settings() or {}
referral_link = None
bot_referral_link = None
if referral_code:
referral_link = settings.get_referral_link(referral_code)
referral_link = settings.get_cabinet_referral_link(referral_code)
bot_referral_link = settings.get_bot_referral_link(referral_code)
minimum_topup_kopeks = int(referral_settings.get('minimum_topup_kopeks') or 0)
first_topup_bonus_kopeks = int(referral_settings.get('first_topup_bonus_kopeks') or 0)
@@ -2967,6 +2969,7 @@ async def _build_referral_info(
return MiniAppReferralInfo(
referral_code=referral_code,
referral_link=referral_link,
bot_referral_link=bot_referral_link,
terms=terms,
stats=stats,
recent_earnings=recent_earnings,
+2 -7
View File
@@ -4,13 +4,11 @@ from datetime import UTC, datetime
from typing import Any, Optional
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.bot_factory import create_bot
from app.database.models import PinnedMessage
from app.services.pinned_message_service import (
broadcast_pinned_message,
@@ -52,10 +50,7 @@ def _serialize_pinned_message(msg: PinnedMessage) -> PinnedMessageResponse:
def _get_bot() -> Bot:
"""Создать экземпляр бота для API операций."""
return Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
return create_bot()
@router.get('', response_model=PinnedMessageListResponse)
+2 -8
View File
@@ -2,9 +2,6 @@ from __future__ import annotations
from typing import Any
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from fastapi import (
APIRouter,
Depends,
@@ -328,12 +325,9 @@ async def send_poll(
total=0,
)
from app.config import settings
from app.bot_factory import create_bot
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
bot = create_bot()
try:
result = await send_poll_to_users(bot, db, poll, users)
+3 -12
View File
@@ -4,13 +4,10 @@ from datetime import UTC, datetime
from typing import Any
import structlog
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Security, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.bot_factory import create_bot
from app.database.crud.ticket import TicketCRUD, TicketMessageCRUD
from app.database.models import Ticket, TicketMessage, TicketStatus
@@ -224,10 +221,7 @@ async def reply_to_ticket(
media_caption=payload.media_caption,
)
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
bot = create_bot()
try:
from app.handlers.admin.tickets import notify_user_about_ticket_reply
@@ -286,10 +280,7 @@ async def get_ticket_message_media(
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Media not found for this message')
media_url: str | None = None
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
bot = create_bot()
try:
file = await bot.get_file(message.media_file_id)
if file.file_path:
+1
View File
@@ -376,6 +376,7 @@ class MiniAppReferralList(BaseModel):
class MiniAppReferralInfo(BaseModel):
referral_code: str | None = None
referral_link: str | None = None
bot_referral_link: str | None = None
terms: MiniAppReferralTerms | None = None
stats: MiniAppReferralStats | None = None
recent_earnings: list[MiniAppReferralRecentEarning] = Field(default_factory=list)
+2 -3
View File
@@ -964,11 +964,10 @@ async def _send_crash_notification_on_error(error: Exception) -> None:
return
try:
from aiogram import Bot
from app.bot_factory import create_bot
from app.services.startup_notification_service import send_crash_notification
bot = Bot(token=settings.BOT_TOKEN)
bot = create_bot()
try:
traceback_str = traceback.format_exc()
await send_crash_notification(bot, error, traceback_str)
@@ -0,0 +1,46 @@
"""add missing indexes for RBAC foreign keys and lower(email) expression
Revision ID: 0043
Revises: 0042
Create Date: 2026-03-20
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = '0043'
down_revision: Union[str, None] = '0042'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.get_context().autocommit_block():
op.execute(
sa.text(
'CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_user_roles_role_id '
'ON user_roles (role_id)'
)
)
op.execute(
sa.text(
'CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_access_policies_role_id '
'ON access_policies (role_id)'
)
)
op.execute(
sa.text(
'CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_users_email_lower '
'ON users (lower(email))'
)
)
def downgrade() -> None:
with op.get_context().autocommit_block():
op.execute(sa.text('DROP INDEX CONCURRENTLY IF EXISTS ix_users_email_lower'))
op.execute(sa.text('DROP INDEX CONCURRENTLY IF EXISTS ix_access_policies_role_id'))
op.execute(sa.text('DROP INDEX CONCURRENTLY IF EXISTS ix_user_roles_role_id'))
+1
View File
@@ -24,6 +24,7 @@ dependencies = [
'pyzipper>=0.3.6',
'structlog>=25.1.0,<26',
'rich>=14.0',
'aiohttp-socks>=0.10.1',
]
[dependency-groups]
+1
View File
@@ -1,6 +1,7 @@
# Основные зависимости
aiogram==3.22.0
aiohttp==3.12.15
aiohttp-socks>=0.10.1
asyncpg==0.31.0
SQLAlchemy==2.0.46
alembic==1.18.4
Generated
+25 -1
View File
@@ -71,6 +71,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" },
]
[[package]]
name = "aiohttp-socks"
version = "0.11.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
{ name = "python-socks" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1f/cc/e5bbd54f76bd56291522251e47267b645dac76327b2657ade9545e30522c/aiohttp_socks-0.11.0.tar.gz", hash = "sha256:0afe51638527c79077e4bd6e57052c87c4824233d6e20bb061c53766421b10f0", size = 11196, upload-time = "2025-12-09T13:35:52.564Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bf/7d/4b633d709b8901d59444d2e512b93e72fe62d2b492a040097c3f7ba017bb/aiohttp_socks-0.11.0-py3-none-any.whl", hash = "sha256:9aacce57c931b8fbf8f6d333cf3cafe4c35b971b35430309e167a35a8aab9ec1", size = 10556, upload-time = "2025-12-09T13:35:50.18Z" },
]
[[package]]
name = "aiosignal"
version = "1.4.0"
@@ -1057,6 +1070,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/aa/76/03af049af4dcee5d27442f71b6924f01f3efb5d2bd34f23fcd563f2cc5f5/python_multipart-0.0.21-py3-none-any.whl", hash = "sha256:cf7a6713e01c87aa35387f4774e812c4361150938d20d232800f75ffcf266090", size = 24541, upload-time = "2025-12-17T09:24:21.153Z" },
]
[[package]]
name = "python-socks"
version = "2.8.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/36/0b/cd77011c1bc01b76404f7aba07fca18aca02a19c7626e329b40201217624/python_socks-2.8.1.tar.gz", hash = "sha256:698daa9616d46dddaffe65b87db222f2902177a2d2b2c0b9a9361df607ab3687", size = 38909, upload-time = "2026-02-16T05:24:00.745Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/15/fe/9a58cb6eec633ff6afae150ca53c16f8cc8b65862ccb3d088051efdfceb7/python_socks-2.8.1-py3-none-any.whl", hash = "sha256:28232739c4988064e725cdbcd15be194743dd23f1c910f784163365b9d7be035", size = 55087, upload-time = "2026-02-16T05:23:59.147Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
@@ -1115,10 +1137,11 @@ wheels = [
[[package]]
name = "remnawave-bedolaga-telegram-bot"
version = "3.34.1"
version = "3.36.0"
source = { virtual = "." }
dependencies = [
{ name = "aiogram" },
{ name = "aiohttp-socks" },
{ name = "aiosqlite" },
{ name = "alembic" },
{ name = "asyncpg" },
@@ -1149,6 +1172,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "aiogram", specifier = ">=3.25.0" },
{ name = "aiohttp-socks", specifier = ">=0.10.1" },
{ name = "aiosqlite", specifier = ">=0.22.1" },
{ name = "alembic", specifier = ">=1.18.4" },
{ name = "asyncpg", specifier = ">=0.31.0" },