@@ -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
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 '',
|
||||
|
||||
@@ -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
@@ -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='Ошибка создания инвойса',
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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)}
|
||||
"""
|
||||
|
||||
@@ -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})",
|
||||
|
||||
@@ -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})",
|
||||
|
||||
@@ -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})",
|
||||
|
||||
@@ -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})",
|
||||
|
||||
@@ -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}页)",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 платеж уже связан с транзакцией',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {})
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. '
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'))
|
||||
@@ -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,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
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user