Compare commits
117 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e7da2a64f8 | |||
| 813193102c | |||
| 42112d6c8a | |||
| dfe5e36fe0 | |||
| b5d4a54e97 | |||
| 1eaf7204ce | |||
| 8cc37dc439 | |||
| ccafb18122 | |||
| 654b132ae6 | |||
| 815fa0560c | |||
| c4d19810a4 | |||
| 9fbb32eb5b | |||
| 3ce3cde859 | |||
| 4e71dcd2b1 | |||
| acf21fa6d2 | |||
| f2b724a78d | |||
| 03473360c1 | |||
| 27dc68be41 | |||
| b15d78f8af | |||
| d232d14937 | |||
| 927d469db2 | |||
| e20e9e7cec | |||
| 0f097c3108 | |||
| eaa3c80d59 | |||
| 0c49eddfde | |||
| 33882f1e93 | |||
| f94666b68d | |||
| d32a49f801 | |||
| 9596f82ea6 | |||
| 0e3fdf7d61 | |||
| 54cfec6029 | |||
| 9e88eae416 | |||
| 8d32efac73 | |||
| 9f07b6007e | |||
| 2fb3b6c5dc | |||
| 0637576425 | |||
| ee7a445610 | |||
| ba4c7ee7ac | |||
| 4da169eb0f | |||
| 28937abab4 | |||
| b1dcf2779e | |||
| 3219062ce2 | |||
| 2ac72951f8 | |||
| cefb8cba78 | |||
| e869655028 | |||
| ae03ede209 | |||
| a72bb25ea2 | |||
| 0b22bc7056 | |||
| cde84ff20b | |||
| f76114d7dc | |||
| 9708a56fd7 | |||
| a360337660 | |||
| 9f78335651 | |||
| ef8142a99b | |||
| 06d51f9fcf | |||
| 4d94347179 | |||
| 2f6aa0b1e6 | |||
| 536a58d4d3 | |||
| 62f7c07059 | |||
| 0648edb3c4 | |||
| 9b6c235424 | |||
| 2fc35950cf | |||
| d2a5d74239 | |||
| 6b04c6748e | |||
| 10b44cbdb7 | |||
| dde04c716c | |||
| 819f19a7ea | |||
| e7201597ab | |||
| ea61aa7053 | |||
| 14546a8485 | |||
| f55455761f | |||
| b1a7767827 | |||
| 43c04b7e64 | |||
| add679cf6f | |||
| ee173190a0 | |||
| a6e21b47f6 | |||
| cd627e5840 | |||
| fc096e6867 | |||
| b591844c4e | |||
| 9557acb546 | |||
| 1fa6e75bd3 | |||
| 8965e43a55 | |||
| 8e7a6beece | |||
| e9f4fd8007 | |||
| a991c59d58 | |||
| dd0624adf8 | |||
| ab51cbe96a | |||
| 2f7a08deb0 | |||
| a0fa7f986b | |||
| b2d3eebe39 | |||
| a043fc0e46 | |||
| d6eec8787e | |||
| 95eee42047 | |||
| c673d6db4f | |||
| bb32a63d48 | |||
| af5acb272c | |||
| 5cd5ac8710 | |||
| 8bb58b44b3 | |||
| 511ecd506f | |||
| 62fc014bc3 | |||
| 961f564274 | |||
| 984870c78c | |||
| 6196c135d2 | |||
| bf1b8315a8 | |||
| b7eabcf7e7 | |||
| e83ebc4f02 | |||
| 1a4758f4cc | |||
| b80e8b3804 | |||
| e036173670 | |||
| bd728c6466 | |||
| e3aec310a3 | |||
| 1c59ce601e | |||
| 0563f7524f | |||
| cedbe07759 | |||
| 0746b2a5a7 | |||
| e8a72860dc | |||
| abbfe4a7d3 |
@@ -36,15 +36,15 @@ jobs:
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🏷️ Собираем релизную версию: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.6.1-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.6.2-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🚀 Собираем версию из main: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.6.1-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.6.2-dev-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🧪 Собираем dev версию: $VERSION"
|
||||
else
|
||||
VERSION="v2.6.1-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.6.2-pr-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Собираем PR версию: $VERSION"
|
||||
fi
|
||||
|
||||
@@ -49,13 +49,13 @@ jobs:
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "🏷️ Building release version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.6.1-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.6.2-$(git rev-parse --short HEAD)"
|
||||
echo "🚀 Building main version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.6.1-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.6.2-dev-$(git rev-parse --short HEAD)"
|
||||
echo "🧪 Building dev version: $VERSION"
|
||||
else
|
||||
VERSION="v2.6.1-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.6.2-pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Building PR version: $VERSION"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
FROM python:3.13-slim AS builder
|
||||
FROM python:3.14-slim AS builder
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
@@ -12,9 +12,9 @@ COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
FROM python:3.13-slim
|
||||
FROM python:3.14-slim
|
||||
|
||||
ARG VERSION="v2.6.1"
|
||||
ARG VERSION="v2.6.2"
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -437,7 +437,7 @@ http {
|
||||
| 👑 **ADMIN_IDS** | Твой Telegram ID | `123456789,987654321` |
|
||||
| **BOT_RUN_MODE** | определяет способ приёма обновлений: `polling`, `webhook` или `both`, чтобы одновременно использовать оба режима.
|
||||
|
||||
[Полный список доступных параметров:](remnawave-bedolaga-telegram-bot/blob/main/.env.example)
|
||||
[Полный список доступных параметров:](.env.example)
|
||||
|
||||
### 🌐 Интеграция веб-админки
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Database package exports."""
|
||||
|
||||
from .database import (
|
||||
DatabaseManager,
|
||||
batch_ops,
|
||||
close_db,
|
||||
db_manager,
|
||||
get_db,
|
||||
get_db_read_only,
|
||||
get_pool_metrics,
|
||||
init_db,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DatabaseManager",
|
||||
"batch_ops",
|
||||
"close_db",
|
||||
"db_manager",
|
||||
"get_db",
|
||||
"get_db_read_only",
|
||||
"get_pool_metrics",
|
||||
"init_db",
|
||||
]
|
||||
+360
-14
@@ -1,31 +1,167 @@
|
||||
import logging
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from typing import AsyncGenerator, Optional
|
||||
from contextlib import asynccontextmanager
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
create_async_engine,
|
||||
async_sessionmaker,
|
||||
AsyncEngine
|
||||
)
|
||||
from sqlalchemy.pool import NullPool, AsyncAdaptedQueuePool
|
||||
from sqlalchemy import event, text, bindparam, inspect
|
||||
from sqlalchemy.engine import Engine
|
||||
import time
|
||||
from app.config import settings
|
||||
from app.database.models import Base
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ============================================================================
|
||||
# PRODUCTION-GRADE CONNECTION POOLING
|
||||
# ============================================================================
|
||||
|
||||
if settings.get_database_url().startswith("sqlite"):
|
||||
poolclass = NullPool
|
||||
pool_kwargs = {}
|
||||
else:
|
||||
poolclass = AsyncAdaptedQueuePool
|
||||
pool_kwargs = {
|
||||
"pool_size": 20,
|
||||
"max_overflow": 30,
|
||||
"pool_timeout": 30,
|
||||
"pool_recycle": 3600,
|
||||
"pool_pre_ping": True,
|
||||
# 🔥 Агрессивная очистка мертвых соединений
|
||||
"pool_reset_on_return": "rollback",
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# ENGINE WITH ADVANCED OPTIMIZATIONS
|
||||
# ============================================================================
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.get_database_url(),
|
||||
poolclass=NullPool,
|
||||
poolclass=poolclass,
|
||||
echo=settings.DEBUG,
|
||||
future=True
|
||||
future=True,
|
||||
**pool_kwargs,
|
||||
|
||||
connect_args={
|
||||
"server_settings": {
|
||||
"application_name": "remnawave_bot",
|
||||
"jit": "on",
|
||||
"statement_timeout": "60000", # 60 секунд
|
||||
"idle_in_transaction_session_timeout": "300000", # 5 минут
|
||||
},
|
||||
"command_timeout": 60,
|
||||
"timeout": 10,
|
||||
} if not settings.get_database_url().startswith("sqlite") else {},
|
||||
|
||||
execution_options={
|
||||
"isolation_level": "READ COMMITTED", # Оптимальный для большинства случаев
|
||||
"compiled_cache_size": 500, # Кеш скомпилированных запросов
|
||||
}
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
# SESSION FACTORY WITH OPTIMIZATIONS
|
||||
# ============================================================================
|
||||
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
bind=engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autoflush=True,
|
||||
autocommit=False
|
||||
autoflush=False, # 🔥 Критично для производительности
|
||||
autocommit=False,
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
# QUERY PERFORMANCE MONITORING
|
||||
# ============================================================================
|
||||
|
||||
if settings.DEBUG:
|
||||
@event.listens_for(Engine, "before_cursor_execute")
|
||||
def before_cursor_execute(conn, cursor, statement, parameters, context, executemany):
|
||||
conn.info.setdefault("query_start_time", []).append(time.time())
|
||||
logger.debug(f"🔍 Executing query: {statement[:100]}...")
|
||||
|
||||
@event.listens_for(Engine, "after_cursor_execute")
|
||||
def after_cursor_execute(conn, cursor, statement, parameters, context, executemany):
|
||||
total = time.time() - conn.info["query_start_time"].pop(-1)
|
||||
if total > 0.1: # Логируем медленные запросы > 100ms
|
||||
logger.warning(f"🐌 Slow query ({total:.3f}s): {statement[:100]}...")
|
||||
else:
|
||||
logger.debug(f"⚡ Query executed in {total:.3f}s")
|
||||
|
||||
# ============================================================================
|
||||
# ADVANCED SESSION MANAGER WITH READ REPLICAS
|
||||
# ============================================================================
|
||||
|
||||
class DatabaseManager:
|
||||
"""Продвинутый менеджер БД с поддержкой реплик и кеширования"""
|
||||
|
||||
def __init__(self):
|
||||
self.engine = engine
|
||||
self.read_replica_engine: Optional[AsyncEngine] = None
|
||||
|
||||
if hasattr(settings, 'DATABASE_READ_REPLICA_URL') and settings.DATABASE_READ_REPLICA_URL:
|
||||
self.read_replica_engine = create_async_engine(
|
||||
settings.DATABASE_READ_REPLICA_URL,
|
||||
poolclass=poolclass,
|
||||
pool_size=30, # Больше для read операций
|
||||
max_overflow=50,
|
||||
pool_pre_ping=True,
|
||||
echo=False,
|
||||
)
|
||||
|
||||
@asynccontextmanager
|
||||
async def session(self, read_only: bool = False):
|
||||
target_engine = self.read_replica_engine if (read_only and self.read_replica_engine) else self.engine
|
||||
|
||||
async_session = async_sessionmaker(
|
||||
bind=target_engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
|
||||
async with async_session() as session:
|
||||
try:
|
||||
yield session
|
||||
if not read_only:
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
async def health_check(self) -> dict:
|
||||
pool = self.engine.pool
|
||||
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
start = time.time()
|
||||
await session.execute(text("SELECT 1"))
|
||||
latency = (time.time() - start) * 1000
|
||||
status = "healthy"
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Database health check failed: {e}")
|
||||
status = "unhealthy"
|
||||
latency = None
|
||||
|
||||
return {
|
||||
"status": status,
|
||||
"latency_ms": round(latency, 2) if latency else None,
|
||||
"pool": _collect_health_pool_metrics(pool),
|
||||
}
|
||||
|
||||
db_manager = DatabaseManager()
|
||||
|
||||
# ============================================================================
|
||||
# SESSION DEPENDENCY FOR FASTAPI/AIOGRAM
|
||||
# ============================================================================
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Стандартная dependency для FastAPI"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
@@ -33,19 +169,229 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
async def get_db_read_only() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Read-only dependency для тяжелых SELECT запросов"""
|
||||
async with db_manager.session(read_only=True) as session:
|
||||
yield session
|
||||
|
||||
# ============================================================================
|
||||
# BATCH OPERATIONS FOR PERFORMANCE
|
||||
# ============================================================================
|
||||
|
||||
class BatchOperations:
|
||||
"""Утилиты для массовых операций"""
|
||||
|
||||
@staticmethod
|
||||
async def bulk_insert(session: AsyncSession, model, data: list[dict], chunk_size: int = 1000):
|
||||
"""Массовая вставка с чанками"""
|
||||
for i in range(0, len(data), chunk_size):
|
||||
chunk = data[i:i + chunk_size]
|
||||
session.add_all([model(**item) for item in chunk])
|
||||
await session.flush()
|
||||
await session.commit()
|
||||
|
||||
@staticmethod
|
||||
async def bulk_update(session: AsyncSession, model, data: list[dict], chunk_size: int = 1000):
|
||||
"""Массовое обновление с чанками"""
|
||||
if not data:
|
||||
return
|
||||
|
||||
primary_keys = [column.name for column in model.__table__.primary_key.columns]
|
||||
if not primary_keys:
|
||||
raise ValueError("Model must have a primary key for bulk_update")
|
||||
|
||||
updatable_columns = [
|
||||
column.name
|
||||
for column in model.__table__.columns
|
||||
if column.name not in primary_keys
|
||||
]
|
||||
|
||||
if not updatable_columns:
|
||||
raise ValueError("No columns available for update in bulk_update")
|
||||
|
||||
stmt = (
|
||||
model.__table__.update()
|
||||
.where(
|
||||
*[
|
||||
getattr(model.__table__.c, pk) == bindparam(pk)
|
||||
for pk in primary_keys
|
||||
]
|
||||
)
|
||||
.values(
|
||||
**{
|
||||
column: bindparam(column, required=False)
|
||||
for column in updatable_columns
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
for i in range(0, len(data), chunk_size):
|
||||
chunk = data[i:i + chunk_size]
|
||||
filtered_chunk = []
|
||||
for item in chunk:
|
||||
missing_keys = [pk for pk in primary_keys if pk not in item]
|
||||
if missing_keys:
|
||||
raise ValueError(
|
||||
f"Missing primary key values {missing_keys} for bulk_update"
|
||||
)
|
||||
|
||||
filtered_item = {
|
||||
key: value
|
||||
for key, value in item.items()
|
||||
if key in primary_keys or key in updatable_columns
|
||||
}
|
||||
filtered_chunk.append(filtered_item)
|
||||
|
||||
await session.execute(stmt, filtered_chunk)
|
||||
await session.commit()
|
||||
|
||||
batch_ops = BatchOperations()
|
||||
|
||||
# ============================================================================
|
||||
# INITIALIZATION AND CLEANUP
|
||||
# ============================================================================
|
||||
|
||||
async def init_db():
|
||||
logger.info("Создание таблиц базы данных...")
|
||||
"""Инициализация БД с оптимизациями"""
|
||||
logger.info("🚀 Создание таблиц базы данных...")
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
if not settings.get_database_url().startswith("sqlite"):
|
||||
logger.info("📊 Создание индексов для оптимизации...")
|
||||
|
||||
async with engine.begin() as conn:
|
||||
indexes = [
|
||||
("users", "CREATE INDEX IF NOT EXISTS idx_users_telegram_id ON users(telegram_id)"),
|
||||
(
|
||||
"subscriptions",
|
||||
"CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON subscriptions(user_id)",
|
||||
),
|
||||
(
|
||||
"subscriptions",
|
||||
"CREATE INDEX IF NOT EXISTS idx_subscriptions_status ON subscriptions(status) WHERE status = 'active'",
|
||||
),
|
||||
(
|
||||
"payments",
|
||||
"CREATE INDEX IF NOT EXISTS idx_payments_created_at ON payments(created_at DESC)",
|
||||
),
|
||||
]
|
||||
|
||||
for table_name, index_sql in indexes:
|
||||
table_exists = await conn.run_sync(lambda sync_conn: inspect(sync_conn).has_table(table_name))
|
||||
|
||||
if not table_exists:
|
||||
logger.debug(
|
||||
"Пропускаем создание индекса %s: таблица %s отсутствует",
|
||||
index_sql,
|
||||
table_name,
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
await conn.execute(text(index_sql))
|
||||
except Exception as e:
|
||||
logger.debug("Index creation skipped for %s: %s", table_name, e)
|
||||
|
||||
logger.info("✅ База данных успешно инициализирована")
|
||||
|
||||
|
||||
health = await db_manager.health_check()
|
||||
logger.info(f"📊 Database health: {health}")
|
||||
|
||||
async def close_db():
|
||||
"""Корректное закрытие всех соединений"""
|
||||
logger.info("🔄 Закрытие соединений с БД...")
|
||||
|
||||
await engine.dispose()
|
||||
logger.info("✅ Подключение к базе данных закрыто")
|
||||
|
||||
if db_manager.read_replica_engine:
|
||||
await db_manager.read_replica_engine.dispose()
|
||||
|
||||
logger.info("✅ Все подключения к базе данных закрыты")
|
||||
|
||||
# ============================================================================
|
||||
# CONNECTION POOL METRICS (для мониторинга)
|
||||
# ============================================================================
|
||||
|
||||
def _pool_counters(pool):
|
||||
"""Return basic pool counters or ``None`` when unsupported."""
|
||||
|
||||
required_methods = ("size", "checkedin", "checkedout", "overflow")
|
||||
|
||||
for method_name in required_methods:
|
||||
method = getattr(pool, method_name, None)
|
||||
if method is None or not callable(method):
|
||||
return None
|
||||
|
||||
size = pool.size()
|
||||
checked_in = pool.checkedin()
|
||||
checked_out = pool.checkedout()
|
||||
overflow = pool.overflow()
|
||||
|
||||
total_connections = size + overflow
|
||||
|
||||
return {
|
||||
"size": size,
|
||||
"checked_in": checked_in,
|
||||
"checked_out": checked_out,
|
||||
"overflow": overflow,
|
||||
"total_connections": total_connections,
|
||||
"utilization_percent": (checked_out / total_connections * 100) if total_connections else 0.0,
|
||||
}
|
||||
|
||||
|
||||
def _collect_health_pool_metrics(pool) -> dict:
|
||||
counters = _pool_counters(pool)
|
||||
|
||||
if counters is None:
|
||||
return {
|
||||
"metrics_available": False,
|
||||
"size": 0,
|
||||
"checked_in": 0,
|
||||
"checked_out": 0,
|
||||
"overflow": 0,
|
||||
"total_connections": 0,
|
||||
"utilization": "0.0%",
|
||||
}
|
||||
|
||||
return {
|
||||
"metrics_available": True,
|
||||
"size": counters["size"],
|
||||
"checked_in": counters["checked_in"],
|
||||
"checked_out": counters["checked_out"],
|
||||
"overflow": counters["overflow"],
|
||||
"total_connections": counters["total_connections"],
|
||||
"utilization": f"{counters['utilization_percent']:.1f}%",
|
||||
}
|
||||
|
||||
|
||||
async def get_pool_metrics() -> dict:
|
||||
"""Детальные метрики пула для Prometheus/Grafana"""
|
||||
pool = engine.pool
|
||||
|
||||
counters = _pool_counters(pool)
|
||||
|
||||
if counters is None:
|
||||
return {
|
||||
"metrics_available": False,
|
||||
"pool_size": 0,
|
||||
"checked_in_connections": 0,
|
||||
"checked_out_connections": 0,
|
||||
"overflow_connections": 0,
|
||||
"total_connections": 0,
|
||||
"max_possible_connections": 0,
|
||||
"pool_utilization_percent": 0.0,
|
||||
}
|
||||
|
||||
return {
|
||||
"metrics_available": True,
|
||||
"pool_size": counters["size"],
|
||||
"checked_in_connections": counters["checked_in"],
|
||||
"checked_out_connections": counters["checked_out"],
|
||||
"overflow_connections": counters["overflow"],
|
||||
"total_connections": counters["total_connections"],
|
||||
"max_possible_connections": counters["total_connections"] + (getattr(pool, "_max_overflow", 0) or 0),
|
||||
"pool_utilization_percent": round(counters["utilization_percent"], 2),
|
||||
}
|
||||
|
||||
@@ -2107,6 +2107,9 @@ async def ensure_promo_groups_setup():
|
||||
addon_discount_column_exists = await check_column_exists(
|
||||
"promo_groups", "apply_discounts_to_addons"
|
||||
)
|
||||
priority_column_exists = await check_column_exists(
|
||||
"promo_groups", "priority"
|
||||
)
|
||||
|
||||
if not addon_discount_column_exists:
|
||||
if db_type == "sqlite":
|
||||
@@ -2289,7 +2292,23 @@ async def ensure_promo_groups_setup():
|
||||
"is_default": True,
|
||||
}
|
||||
|
||||
if addon_discount_column_exists:
|
||||
if priority_column_exists:
|
||||
insert_params["priority"] = 0
|
||||
|
||||
if addon_discount_column_exists and priority_column_exists:
|
||||
insert_sql = """
|
||||
INSERT INTO promo_groups (
|
||||
name,
|
||||
priority,
|
||||
server_discount_percent,
|
||||
traffic_discount_percent,
|
||||
device_discount_percent,
|
||||
apply_discounts_to_addons,
|
||||
is_default
|
||||
) VALUES (:name, :priority, 0, 0, 0, :apply_discounts_to_addons, :is_default)
|
||||
"""
|
||||
insert_params["apply_discounts_to_addons"] = True
|
||||
elif addon_discount_column_exists:
|
||||
insert_sql = """
|
||||
INSERT INTO promo_groups (
|
||||
name,
|
||||
@@ -2301,6 +2320,17 @@ async def ensure_promo_groups_setup():
|
||||
) VALUES (:name, 0, 0, 0, :apply_discounts_to_addons, :is_default)
|
||||
"""
|
||||
insert_params["apply_discounts_to_addons"] = True
|
||||
elif priority_column_exists:
|
||||
insert_sql = """
|
||||
INSERT INTO promo_groups (
|
||||
name,
|
||||
priority,
|
||||
server_discount_percent,
|
||||
traffic_discount_percent,
|
||||
device_discount_percent,
|
||||
is_default
|
||||
) VALUES (:name, :priority, 0, 0, 0, :is_default)
|
||||
"""
|
||||
else:
|
||||
insert_sql = """
|
||||
INSERT INTO promo_groups (
|
||||
|
||||
Vendored
+4
-4
@@ -1,4 +1,4 @@
|
||||
"""Flask webhook server for PayPalych postbacks."""
|
||||
"""Flask webhook server for PayPalych callbacks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -65,7 +65,7 @@ def create_pal24_flask_app(
|
||||
return jsonify({"status": "error", "reason": "empty_payload"}), 400
|
||||
|
||||
try:
|
||||
parsed_payload = pal24_service.parse_postback(payload)
|
||||
parsed_payload = pal24_service.parse_callback(payload)
|
||||
except Pal24APIError as error:
|
||||
logger.error("Ошибка валидации Pal24 webhook: %s", error)
|
||||
return jsonify({"status": "error", "reason": str(error)}), 400
|
||||
@@ -73,7 +73,7 @@ def create_pal24_flask_app(
|
||||
async def process() -> bool:
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
return await payment_service.process_pal24_postback(db, parsed_payload)
|
||||
return await payment_service.process_pal24_callback(db, parsed_payload)
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
raise
|
||||
@@ -112,7 +112,7 @@ def create_pal24_flask_app(
|
||||
|
||||
|
||||
class Pal24WebhookServer:
|
||||
"""Threaded Flask server for Pal24 postbacks."""
|
||||
"""Threaded Flask server for Pal24 callbacks."""
|
||||
|
||||
def __init__(self, payment_service: PaymentService, loop: AbstractEventLoop) -> None:
|
||||
self.app = create_pal24_flask_app(payment_service, loop)
|
||||
|
||||
Vendored
+78
-33
@@ -33,6 +33,32 @@ YOOKASSA_ALLOWED_IP_NETWORKS: tuple[IPNetwork, ...] = (
|
||||
)
|
||||
|
||||
|
||||
CLOUDFLARE_TRUSTED_NETWORKS: tuple[IPNetwork, ...] = (
|
||||
ip_network("173.245.48.0/20"),
|
||||
ip_network("103.21.244.0/22"),
|
||||
ip_network("103.22.200.0/22"),
|
||||
ip_network("103.31.4.0/22"),
|
||||
ip_network("141.101.64.0/18"),
|
||||
ip_network("108.162.192.0/18"),
|
||||
ip_network("190.93.240.0/20"),
|
||||
ip_network("188.114.96.0/20"),
|
||||
ip_network("197.234.240.0/22"),
|
||||
ip_network("198.41.128.0/17"),
|
||||
ip_network("162.158.0.0/15"),
|
||||
ip_network("104.16.0.0/13"),
|
||||
ip_network("104.24.0.0/14"),
|
||||
ip_network("172.64.0.0/13"),
|
||||
ip_network("131.0.72.0/22"),
|
||||
ip_network("2400:cb00::/32"),
|
||||
ip_network("2606:4700::/32"),
|
||||
ip_network("2803:f800::/32"),
|
||||
ip_network("2405:b500::/32"),
|
||||
ip_network("2405:8100::/32"),
|
||||
ip_network("2a06:98c0::/29"),
|
||||
ip_network("2c0f:f248::/32"),
|
||||
)
|
||||
|
||||
|
||||
YOOKASSA_ALLOWED_EVENTS: tuple[str, ...] = (
|
||||
"payment.succeeded",
|
||||
"payment.waiting_for_capture",
|
||||
@@ -122,6 +148,9 @@ def _is_trusted_proxy_ip(ip_object: IPAddress) -> bool:
|
||||
):
|
||||
return True
|
||||
|
||||
if any(ip_object in network for network in CLOUDFLARE_TRUSTED_NETWORKS):
|
||||
return True
|
||||
|
||||
return any(ip_object in network for network in _get_trusted_proxy_networks())
|
||||
|
||||
|
||||
@@ -167,10 +196,10 @@ def is_yookassa_ip_allowed(ip_object: IPAddress) -> bool:
|
||||
|
||||
|
||||
class YooKassaWebhookHandler:
|
||||
|
||||
|
||||
def __init__(self, payment_service: PaymentService):
|
||||
self.payment_service = payment_service
|
||||
|
||||
|
||||
async def handle_webhook(self, request: web.Request) -> web.Response:
|
||||
|
||||
try:
|
||||
@@ -180,6 +209,7 @@ class YooKassaWebhookHandler:
|
||||
header_ip_candidates = collect_yookassa_ip_candidates(
|
||||
request.headers.get("X-Forwarded-For"),
|
||||
request.headers.get("X-Real-IP"),
|
||||
request.headers.get("Cf-Connecting-Ip"),
|
||||
)
|
||||
client_ip = resolve_yookassa_ip(
|
||||
header_ip_candidates,
|
||||
@@ -220,46 +250,61 @@ class YooKassaWebhookHandler:
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"❌ Ошибка парсинга JSON webhook YooKassa: {e}")
|
||||
return web.Response(status=400, text="Invalid JSON")
|
||||
|
||||
|
||||
logger.info(f"📊 Обработка webhook YooKassa: {webhook_data.get('event', 'unknown_event')}")
|
||||
logger.debug(f"🔍 Полные данные webhook: {webhook_data}")
|
||||
|
||||
|
||||
# Извлекаем ID платежа из вебхука для предотвращения дублирования
|
||||
yookassa_payment_id = webhook_data.get("object", {}).get("id")
|
||||
if not yookassa_payment_id:
|
||||
logger.warning("⚠️ Webhook YooKassa без ID платежа")
|
||||
return web.Response(status=400, text="No payment ID")
|
||||
|
||||
event_type = webhook_data.get("event")
|
||||
if not event_type:
|
||||
logger.warning("⚠️ Webhook YooKassa без типа события")
|
||||
return web.Response(status=400, text="No event type")
|
||||
|
||||
|
||||
if event_type not in YOOKASSA_ALLOWED_EVENTS:
|
||||
logger.info(f"ℹ️ Игнорируем событие YooKassa: {event_type}")
|
||||
return web.Response(status=200, text="OK")
|
||||
|
||||
|
||||
async for db in get_db():
|
||||
try:
|
||||
success = await self.payment_service.process_yookassa_webhook(db, webhook_data)
|
||||
# Проверяем, не обрабатывается ли этот платеж уже (защита от дублирования)
|
||||
from app.database.models import PaymentMethod
|
||||
from app.database.crud.transaction import get_transaction_by_external_id
|
||||
existing_transaction = await get_transaction_by_external_id(db, yookassa_payment_id, PaymentMethod.YOOKASSA)
|
||||
|
||||
if existing_transaction and event_type == "payment.succeeded":
|
||||
logger.info(f"ℹ️ Платеж YooKassa {yookassa_payment_id} уже был обработан. Пропускаем дублирующий вебхук.")
|
||||
return web.Response(status=200, text="OK")
|
||||
|
||||
success = await self.payment_service.process_yookassa_webhook(db, webhook_data)
|
||||
|
||||
if success:
|
||||
logger.info(f"✅ Успешно обработан webhook YooKassa: {event_type}")
|
||||
logger.info(f"✅ Успешно обработан webhook YooKassa: {event_type} для платежа {yookassa_payment_id}")
|
||||
return web.Response(status=200, text="OK")
|
||||
else:
|
||||
logger.error(f"❌ Ошибка обработки webhook YooKassa: {event_type}")
|
||||
logger.error(f"❌ Ошибка обработки webhook YooKassa: {event_type} для платежа {yookassa_payment_id}")
|
||||
return web.Response(status=500, text="Processing error")
|
||||
|
||||
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Критическая ошибка обработки webhook YooKassa: {e}", exc_info=True)
|
||||
return web.Response(status=500, text="Internal server error")
|
||||
|
||||
|
||||
def setup_routes(self, app: web.Application) -> None:
|
||||
|
||||
|
||||
webhook_path = settings.YOOKASSA_WEBHOOK_PATH
|
||||
app.router.add_post(webhook_path, self.handle_webhook)
|
||||
app.router.add_get(webhook_path, self._get_handler)
|
||||
app.router.add_options(webhook_path, self._options_handler)
|
||||
|
||||
app.router.add_get(webhook_path, self._get_handler)
|
||||
app.router.add_options(webhook_path, self._options_handler)
|
||||
|
||||
logger.info(f"✅ Настроен YooKassa webhook на пути: POST {webhook_path}")
|
||||
|
||||
|
||||
async def _get_handler(self, request: web.Request) -> web.Response:
|
||||
return web.json_response({
|
||||
"status": "ok",
|
||||
@@ -268,7 +313,7 @@ class YooKassaWebhookHandler:
|
||||
"path": request.path,
|
||||
"note": "Use POST method for actual webhooks"
|
||||
})
|
||||
|
||||
|
||||
async def _options_handler(self, request: web.Request) -> web.Response:
|
||||
return web.Response(
|
||||
status=200,
|
||||
@@ -281,46 +326,46 @@ class YooKassaWebhookHandler:
|
||||
|
||||
|
||||
def create_yookassa_webhook_app(payment_service: PaymentService) -> web.Application:
|
||||
|
||||
|
||||
app = web.Application()
|
||||
|
||||
|
||||
webhook_handler = YooKassaWebhookHandler(payment_service)
|
||||
webhook_handler.setup_routes(app)
|
||||
|
||||
|
||||
async def health_check(request):
|
||||
return web.json_response({
|
||||
"status": "ok",
|
||||
"status": "ok",
|
||||
"service": "yookassa_webhook",
|
||||
"port": settings.YOOKASSA_WEBHOOK_PORT,
|
||||
"path": settings.YOOKASSA_WEBHOOK_PATH,
|
||||
"enabled": settings.is_yookassa_enabled()
|
||||
})
|
||||
|
||||
|
||||
app.router.add_get("/health", health_check)
|
||||
|
||||
|
||||
return app
|
||||
|
||||
|
||||
async def start_yookassa_webhook_server(payment_service: PaymentService) -> None:
|
||||
|
||||
|
||||
if not settings.is_yookassa_enabled():
|
||||
logger.info("ℹ️ YooKassa отключена, webhook сервер не запускается")
|
||||
return
|
||||
|
||||
|
||||
try:
|
||||
app = create_yookassa_webhook_app(payment_service)
|
||||
|
||||
|
||||
runner = web.AppRunner(app)
|
||||
await runner.setup()
|
||||
|
||||
|
||||
site = web.TCPSite(
|
||||
runner,
|
||||
host=settings.YOOKASSA_WEBHOOK_HOST,
|
||||
port=settings.YOOKASSA_WEBHOOK_PORT
|
||||
)
|
||||
|
||||
|
||||
await site.start()
|
||||
|
||||
|
||||
logger.info(
|
||||
"✅ YooKassa webhook сервер запущен на %s:%s",
|
||||
settings.YOOKASSA_WEBHOOK_HOST,
|
||||
@@ -332,7 +377,7 @@ async def start_yookassa_webhook_server(payment_service: PaymentService) -> None
|
||||
settings.YOOKASSA_WEBHOOK_PORT,
|
||||
settings.YOOKASSA_WEBHOOK_PATH,
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
@@ -342,7 +387,7 @@ async def start_yookassa_webhook_server(payment_service: PaymentService) -> None
|
||||
await site.stop()
|
||||
await runner.cleanup()
|
||||
logger.info("✅ YooKassa webhook сервер остановлен")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка запуска YooKassa webhook сервера: {e}", exc_info=True)
|
||||
raise
|
||||
raise
|
||||
@@ -1,5 +1,7 @@
|
||||
import html
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from aiogram import types
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -423,8 +425,77 @@ async def check_pal24_payment_status(
|
||||
if not isinstance(links_meta, dict):
|
||||
links_meta = {}
|
||||
|
||||
sbp_link = links_meta.get("sbp") or payment.link_url
|
||||
card_link = links_meta.get("card")
|
||||
links_info = status_info.get("links") or {}
|
||||
|
||||
def _extract_link(source: Any, keys: tuple[str, ...]) -> Optional[str]:
|
||||
stack: list[Any] = [source]
|
||||
while stack:
|
||||
current = stack.pop()
|
||||
if isinstance(current, dict):
|
||||
for key in keys:
|
||||
value = current.get(key)
|
||||
if value:
|
||||
return str(value)
|
||||
stack.extend(current.values())
|
||||
elif isinstance(current, list):
|
||||
stack.extend(current)
|
||||
return None
|
||||
|
||||
raw_response = metadata.get("raw_response") if isinstance(metadata, dict) else None
|
||||
remote_data = status_info.get("remote_data")
|
||||
transfer_keys = (
|
||||
"transfer_url",
|
||||
"transferUrl",
|
||||
"transfer_link",
|
||||
"transferLink",
|
||||
"transfer",
|
||||
"sbp_url",
|
||||
"sbpUrl",
|
||||
"sbp_link",
|
||||
"sbpLink",
|
||||
)
|
||||
card_keys = (
|
||||
"link_url",
|
||||
"linkUrl",
|
||||
"link",
|
||||
"card_url",
|
||||
"cardUrl",
|
||||
"card_link",
|
||||
"cardLink",
|
||||
"payment_url",
|
||||
"paymentUrl",
|
||||
"url",
|
||||
)
|
||||
|
||||
extra_sbp_link = (
|
||||
_extract_link(raw_response, transfer_keys)
|
||||
if raw_response
|
||||
else None
|
||||
)
|
||||
if not extra_sbp_link and remote_data:
|
||||
extra_sbp_link = _extract_link(remote_data, transfer_keys)
|
||||
|
||||
extra_card_link = (
|
||||
_extract_link(raw_response, card_keys)
|
||||
if raw_response
|
||||
else None
|
||||
)
|
||||
if not extra_card_link and remote_data:
|
||||
extra_card_link = _extract_link(remote_data, card_keys)
|
||||
|
||||
sbp_link = (
|
||||
links_info.get("sbp")
|
||||
or links_meta.get("sbp")
|
||||
or status_info.get("sbp_url")
|
||||
or extra_sbp_link
|
||||
or payment.link_url
|
||||
)
|
||||
card_link = (
|
||||
links_info.get("card")
|
||||
or links_meta.get("card")
|
||||
or status_info.get("card_url")
|
||||
or extra_card_link
|
||||
)
|
||||
|
||||
if not card_link and payment.link_page_url and payment.link_page_url != sbp_link:
|
||||
card_link = payment.link_page_url
|
||||
@@ -459,10 +530,53 @@ async def check_pal24_payment_status(
|
||||
db_user = getattr(callback, 'db_user', None)
|
||||
texts = get_texts(db_user.language if db_user else 'ru') if db_user else get_texts('ru')
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"), callback_data=f"check_pal24_{local_payment_id}")],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
||||
])
|
||||
pay_rows: list[list[types.InlineKeyboardButton]] = []
|
||||
|
||||
if not payment.is_paid and payment.status in {"NEW", "PROCESS"}:
|
||||
default_sbp_text = texts.t(
|
||||
"PAL24_SBP_PAY_BUTTON",
|
||||
"🏦 Оплатить через PayPalych (СБП)",
|
||||
)
|
||||
sbp_button_text = settings.get_pal24_sbp_button_text(default_sbp_text)
|
||||
|
||||
if sbp_link and settings.is_pal24_sbp_button_visible():
|
||||
pay_rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=sbp_button_text,
|
||||
url=sbp_link,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
default_card_text = texts.t(
|
||||
"PAL24_CARD_PAY_BUTTON",
|
||||
"💳 Оплатить банковской картой (PayPalych)",
|
||||
)
|
||||
card_button_text = settings.get_pal24_card_button_text(default_card_text)
|
||||
|
||||
if card_link and settings.is_pal24_card_button_visible():
|
||||
if not pay_rows or pay_rows[-1][0].url != card_link:
|
||||
pay_rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=card_button_text,
|
||||
url=card_link,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
keyboard_rows = pay_rows + [
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
|
||||
callback_data=f"check_pal24_{local_payment_id}",
|
||||
)
|
||||
],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
|
||||
]
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
|
||||
|
||||
await callback.answer()
|
||||
try:
|
||||
|
||||
@@ -32,11 +32,25 @@ async def _prompt_amount(
|
||||
texts = get_texts(db_user.language)
|
||||
method_name = settings.get_platega_method_display_title(method_code)
|
||||
|
||||
min_amount_label = settings.format_price(settings.PLATEGA_MIN_AMOUNT_KOPEKS)
|
||||
max_amount_kopeks = settings.PLATEGA_MAX_AMOUNT_KOPEKS
|
||||
max_amount_label = (
|
||||
settings.format_price(max_amount_kopeks)
|
||||
if max_amount_kopeks and max_amount_kopeks > 0
|
||||
else ""
|
||||
)
|
||||
|
||||
default_prompt_body = (
|
||||
"Введите сумму для пополнения от {min_amount} до {max_amount}.\n"
|
||||
if max_amount_kopeks and max_amount_kopeks > 0
|
||||
else "Введите сумму для пополнения от {min_amount}.\n"
|
||||
)
|
||||
|
||||
prompt_template = texts.t(
|
||||
"PLATEGA_TOPUP_PROMPT",
|
||||
(
|
||||
"💳 <b>Оплата через Platega ({method_name})</b>\n\n"
|
||||
"Введите сумму для пополнения от 100 до 1 000 000 ₽.\n"
|
||||
f"{default_prompt_body}"
|
||||
"Оплата происходит через Platega."
|
||||
),
|
||||
)
|
||||
@@ -51,7 +65,11 @@ async def _prompt_amount(
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
await message.edit_text(
|
||||
prompt_template.format(method_name=method_name),
|
||||
prompt_template.format(
|
||||
method_name=method_name,
|
||||
min_amount=min_amount_label,
|
||||
max_amount=max_amount_label,
|
||||
),
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
@@ -1655,8 +1655,18 @@ async def check_simple_pal24_payment_status(
|
||||
if not isinstance(links_meta, dict):
|
||||
links_meta = {}
|
||||
|
||||
sbp_link = links_meta.get("sbp") or payment.link_url
|
||||
card_link = links_meta.get("card")
|
||||
links_info = status_info.get("links") or {}
|
||||
sbp_link = (
|
||||
links_info.get("sbp")
|
||||
or links_meta.get("sbp")
|
||||
or status_info.get("sbp_url")
|
||||
or payment.link_url
|
||||
)
|
||||
card_link = (
|
||||
links_info.get("card")
|
||||
or links_meta.get("card")
|
||||
or status_info.get("card_url")
|
||||
)
|
||||
if not card_link and payment.link_page_url and payment.link_page_url != sbp_link:
|
||||
card_link = payment.link_page_url
|
||||
|
||||
@@ -1689,8 +1699,45 @@ async def check_simple_pal24_payment_status(
|
||||
f"❌ Платеж не завершен корректно. Обратитесь в {settings.get_support_contact_display()}",
|
||||
]
|
||||
|
||||
pay_rows: list[list[types.InlineKeyboardButton]] = []
|
||||
|
||||
if not payment.is_paid and payment.status in {"NEW", "PROCESS"}:
|
||||
default_sbp_text = texts.t(
|
||||
"PAL24_SBP_PAY_BUTTON",
|
||||
"🏦 Оплатить через PayPalych (СБП)",
|
||||
)
|
||||
sbp_button_text = settings.get_pal24_sbp_button_text(default_sbp_text)
|
||||
|
||||
if sbp_link and settings.is_pal24_sbp_button_visible():
|
||||
pay_rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=sbp_button_text,
|
||||
url=sbp_link,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
default_card_text = texts.t(
|
||||
"PAL24_CARD_PAY_BUTTON",
|
||||
"💳 Оплатить банковской картой (PayPalych)",
|
||||
)
|
||||
card_button_text = settings.get_pal24_card_button_text(default_card_text)
|
||||
|
||||
if card_link and settings.is_pal24_card_button_visible():
|
||||
if not pay_rows or pay_rows[-1][0].url != card_link:
|
||||
pay_rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=card_button_text,
|
||||
url=card_link,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
inline_keyboard=pay_rows
|
||||
+ [
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
|
||||
|
||||
@@ -503,7 +503,10 @@ async def activate_trial(
|
||||
|
||||
await callback.message.edit_text(
|
||||
message,
|
||||
reply_markup=get_insufficient_balance_keyboard(db_user.language),
|
||||
reply_markup=get_insufficient_balance_keyboard(
|
||||
db_user.language,
|
||||
amount_kopeks=error.required_amount,
|
||||
),
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
@@ -565,7 +568,10 @@ async def activate_trial(
|
||||
|
||||
await callback.message.edit_text(
|
||||
message,
|
||||
reply_markup=get_insufficient_balance_keyboard(db_user.language),
|
||||
reply_markup=get_insufficient_balance_keyboard(
|
||||
db_user.language,
|
||||
amount_kopeks=error.required_amount,
|
||||
),
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
@@ -1061,6 +1061,8 @@
|
||||
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Bank card ({mulenpay_name})</b>",
|
||||
"PAYMENT_METHOD_PAL24_DESCRIPTION": "via Faster Payments System",
|
||||
"PAYMENT_METHOD_PAL24_NAME": "🏦 <b>SBP (PayPalych)</b>",
|
||||
"PAYMENT_METHOD_PLATEGA_DESCRIPTION": "via Platega (cards + SBP)",
|
||||
"PAYMENT_METHOD_PLATEGA_NAME": "💳 <b>Bank card (Platega)</b>",
|
||||
"PAYMENT_METHOD_STARS_DESCRIPTION": "fast and convenient",
|
||||
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
|
||||
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "other options",
|
||||
@@ -1079,7 +1081,7 @@
|
||||
"PAYMENT_SBP_YOOKASSA": "🏦 Pay via SBP (YooKassa)",
|
||||
"PAYMENT_TELEGRAM_STARS": "⭐ Telegram Stars",
|
||||
"PAYMENT_VIA_SUPPORT": "🛠️ Via support",
|
||||
"PLATEGA_TOPUP_PROMPT": "💳 <b>Payment via Platega ({method_name})</b>\n\nEnter the amount from 100 to 1,000,000 ₽.\nPayment is processed by Platega.",
|
||||
"PLATEGA_TOPUP_PROMPT": "💳 <b>Payment via Platega ({method_name})</b>\n\nEnter an amount from {min_amount} to {max_amount}.\nPayment is processed by Platega.",
|
||||
"PLATEGA_SELECT_PAYMENT_METHOD": "Choose a Platega payment method:",
|
||||
"PLATEGA_TEMPORARILY_UNAVAILABLE": "❌ Platega payments are temporarily unavailable",
|
||||
"PLATEGA_METHODS_NOT_CONFIGURED": "⚠️ No active Platega methods configured",
|
||||
|
||||
@@ -1081,6 +1081,8 @@
|
||||
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Банковская карта ({mulenpay_name})</b>",
|
||||
"PAYMENT_METHOD_PAL24_DESCRIPTION": "через систему быстрых платежей",
|
||||
"PAYMENT_METHOD_PAL24_NAME": "🏦 <b>СБП (PayPalych)</b>",
|
||||
"PAYMENT_METHOD_PLATEGA_DESCRIPTION": "через Platega (карты + СБП)",
|
||||
"PAYMENT_METHOD_PLATEGA_NAME": "💳 <b>Банковская карта (Platega)</b>",
|
||||
"PAYMENT_METHOD_STARS_DESCRIPTION": "быстро и удобно",
|
||||
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
|
||||
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "другие способы",
|
||||
@@ -1099,7 +1101,7 @@
|
||||
"PAYMENT_SBP_YOOKASSA": "🏬 Оплатить по СБП (YooKassa)",
|
||||
"PAYMENT_TELEGRAM_STARS": "⭐ Telegram Stars",
|
||||
"PAYMENT_VIA_SUPPORT": "🛠️ Через поддержку",
|
||||
"PLATEGA_TOPUP_PROMPT": "💳 <b>Оплата через Platega ({method_name})</b>\n\nВведите сумму для пополнения от 100 до 1 000 000 ₽.\nОплата происходит через Platega.",
|
||||
"PLATEGA_TOPUP_PROMPT": "💳 <b>Оплата через Platega ({method_name})</b>\n\nВведите сумму для пополнения от {min_amount} до {max_amount}.\nОплата происходит через Platega.",
|
||||
"PLATEGA_SELECT_PAYMENT_METHOD": "Выберите способ оплаты Platega:",
|
||||
"PLATEGA_TEMPORARILY_UNAVAILABLE": "❌ Оплата через Platega временно недоступна",
|
||||
"PLATEGA_METHODS_NOT_CONFIGURED": "⚠️ На стороне Platega нет доступных методов оплаты",
|
||||
|
||||
+794
-259
File diff suppressed because it is too large
Load Diff
@@ -288,12 +288,30 @@ class MaintenanceService:
|
||||
self._status.last_check = datetime.utcnow()
|
||||
|
||||
auth_params = settings.get_remnawave_auth_params()
|
||||
base_url = (auth_params.get("base_url") or "").strip()
|
||||
api_key = (auth_params.get("api_key") or "").strip()
|
||||
secret_key = (auth_params.get("secret_key") or "").strip() or None
|
||||
username = (auth_params.get("username") or "").strip() or None
|
||||
password = (auth_params.get("password") or "").strip() or None
|
||||
|
||||
if not base_url:
|
||||
logger.error("REMNAWAVE_API_URL не настроен, пропускаем проверку API")
|
||||
self._status.api_status = False
|
||||
self._status.consecutive_failures = 0
|
||||
return False
|
||||
|
||||
if not api_key:
|
||||
logger.error("REMNAWAVE_API_KEY не настроен, пропускаем проверку API")
|
||||
self._status.api_status = False
|
||||
self._status.consecutive_failures = 0
|
||||
return False
|
||||
|
||||
api = RemnaWaveAPI(
|
||||
base_url=auth_params["base_url"],
|
||||
api_key=auth_params["api_key"],
|
||||
secret_key=auth_params["secret_key"],
|
||||
username=auth_params["username"],
|
||||
password=auth_params["password"]
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
secret_key=secret_key,
|
||||
username=username,
|
||||
password=password
|
||||
)
|
||||
|
||||
attempts = settings.get_maintenance_retry_attempts()
|
||||
|
||||
@@ -937,7 +937,10 @@ class MonitoringService:
|
||||
if not user:
|
||||
continue
|
||||
|
||||
renewal_cost = settings.PRICE_30_DAYS
|
||||
# Правильный расчет стоимости продления с учетом всех параметров подписки
|
||||
renewal_cost = await self.subscription_service.calculate_renewal_price(
|
||||
subscription, 30, db, user=user
|
||||
)
|
||||
promo_discount_percent = self._get_user_promo_offer_discount_percent(user)
|
||||
charge_amount = renewal_cost
|
||||
promo_discount_value = 0
|
||||
|
||||
@@ -90,21 +90,21 @@ class Pal24Service:
|
||||
return await self.client.get_bill_payments(bill_id)
|
||||
|
||||
@staticmethod
|
||||
def parse_postback(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
def parse_callback(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
required_fields = ["InvId", "OutSum", "Status", "SignatureValue"]
|
||||
missing = [field for field in required_fields if field not in payload]
|
||||
if missing:
|
||||
raise Pal24APIError(f"Pal24 postback missing fields: {', '.join(missing)}")
|
||||
raise Pal24APIError(f"Pal24 callback missing fields: {', '.join(missing)}")
|
||||
|
||||
inv_id = str(payload["InvId"])
|
||||
out_sum = str(payload["OutSum"])
|
||||
signature = str(payload["SignatureValue"])
|
||||
|
||||
if not Pal24Client.verify_signature(out_sum, inv_id, signature):
|
||||
raise Pal24APIError("Pal24 postback signature mismatch")
|
||||
raise Pal24APIError("Pal24 callback signature mismatch")
|
||||
|
||||
logger.info(
|
||||
"Получен Pal24 postback: InvId=%s, Status=%s, TrsId=%s",
|
||||
"Получен Pal24 callback: InvId=%s, Status=%s, TrsId=%s",
|
||||
inv_id,
|
||||
payload.get("Status"),
|
||||
payload.get("TrsId"),
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""Mixin с логикой обработки платежей CryptoBot."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from importlib import import_module
|
||||
from typing import Any, Dict, Optional
|
||||
@@ -10,6 +11,7 @@ from typing import Any, Dict, Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
@@ -20,6 +22,33 @@ from app.utils.user_utils import format_referrer_info
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _AdminNotificationContext:
|
||||
user_id: int
|
||||
transaction_id: int
|
||||
old_balance: int
|
||||
topup_status: str
|
||||
referrer_info: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _UserNotificationPayload:
|
||||
telegram_id: int
|
||||
text: str
|
||||
parse_mode: Optional[str]
|
||||
reply_markup: Any
|
||||
amount_rubles: float
|
||||
asset: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _SavedCartNotificationPayload:
|
||||
telegram_id: int
|
||||
text: str
|
||||
reply_markup: Any
|
||||
user_id: int
|
||||
|
||||
|
||||
class CryptoBotPaymentMixin:
|
||||
"""Mixin, отвечающий за генерацию инвойсов CryptoBot и обработку webhook."""
|
||||
|
||||
@@ -149,14 +178,16 @@ class CryptoBotPaymentMixin:
|
||||
|
||||
try:
|
||||
amount_rubles = await currency_converter.usd_to_rub(amount_usd)
|
||||
amount_kopeks = int(amount_rubles * 100)
|
||||
amount_rubles_rounded = math.ceil(amount_rubles)
|
||||
amount_kopeks = int(amount_rubles_rounded * 100)
|
||||
conversion_rate = (
|
||||
amount_rubles / amount_usd if amount_usd > 0 else 0
|
||||
)
|
||||
logger.info(
|
||||
"Конвертация USD->RUB: $%s -> %s₽ (курс: %.2f)",
|
||||
"Конвертация USD->RUB: $%s -> %s₽ (округлено до %s₽, курс: %.2f)",
|
||||
amount_usd,
|
||||
amount_rubles,
|
||||
amount_rubles_rounded,
|
||||
conversion_rate,
|
||||
)
|
||||
except Exception as error:
|
||||
@@ -166,7 +197,8 @@ class CryptoBotPaymentMixin:
|
||||
error,
|
||||
)
|
||||
amount_rubles = amount_usd
|
||||
amount_kopeks = int(amount_usd * 100)
|
||||
amount_rubles_rounded = math.ceil(amount_rubles)
|
||||
amount_kopeks = int(amount_rubles_rounded * 100)
|
||||
conversion_rate = 1.0
|
||||
|
||||
if amount_kopeks <= 0:
|
||||
@@ -185,7 +217,7 @@ class CryptoBotPaymentMixin:
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=(
|
||||
"Пополнение через CryptoBot "
|
||||
f"({updated_payment.amount} {updated_payment.asset} → {amount_rubles:.2f}₽)"
|
||||
f"({updated_payment.amount} {updated_payment.asset} → {amount_rubles_rounded:.2f}₽)"
|
||||
),
|
||||
payment_method=PaymentMethod.CRYPTOBOT,
|
||||
external_id=invoice_id,
|
||||
@@ -211,8 +243,6 @@ class CryptoBotPaymentMixin:
|
||||
user.balance_kopeks += amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
promo_group = user.get_primary_promo_group()
|
||||
subscription = getattr(user, "subscription", None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
topup_status = (
|
||||
"🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
|
||||
@@ -241,55 +271,41 @@ class CryptoBotPaymentMixin:
|
||||
|
||||
await db.refresh(user)
|
||||
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
from app.services.admin_notification_service import (
|
||||
AdminNotificationService,
|
||||
)
|
||||
admin_notification: Optional[_AdminNotificationContext] = None
|
||||
user_notification: Optional[_UserNotificationPayload] = None
|
||||
saved_cart_notification: Optional[_SavedCartNotificationPayload] = None
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления о пополнении CryptoBot: %s",
|
||||
error,
|
||||
)
|
||||
bot_instance = getattr(self, "bot", None)
|
||||
if bot_instance:
|
||||
admin_notification = _AdminNotificationContext(
|
||||
user_id=user.id,
|
||||
transaction_id=transaction.id,
|
||||
old_balance=old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
)
|
||||
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
(
|
||||
"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
|
||||
f"🪙 Платеж: {updated_payment.amount} {updated_payment.asset}\n"
|
||||
f"💱 Курс: 1 USD = {conversion_rate:.2f}₽\n"
|
||||
f"🆔 Транзакция: {invoice_id[:8]}...\n\n"
|
||||
"Баланс пополнен автоматически!"
|
||||
),
|
||||
message_text = (
|
||||
"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
|
||||
f"🪙 Платеж: {updated_payment.amount} {updated_payment.asset}\n"
|
||||
f"💱 Курс: 1 USD = {conversion_rate:.2f}₽\n"
|
||||
f"🆔 Транзакция: {invoice_id[:8]}...\n\n"
|
||||
"Баланс пополнен автоматически!"
|
||||
)
|
||||
user_notification = _UserNotificationPayload(
|
||||
telegram_id=user.telegram_id,
|
||||
text=message_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.info(
|
||||
"✅ Отправлено уведомление пользователю %s о пополнении на %s₽ (%s)",
|
||||
user.telegram_id,
|
||||
f"{amount_rubles:.2f}",
|
||||
updated_payment.asset,
|
||||
amount_rubles=amount_rubles_rounded,
|
||||
asset=updated_payment.asset,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления о пополнении CryptoBot: %s",
|
||||
"Ошибка подготовки уведомления о пополнении CryptoBot: %s",
|
||||
error,
|
||||
)
|
||||
|
||||
@@ -305,7 +321,7 @@ class CryptoBotPaymentMixin:
|
||||
auto_purchase_success = await auto_purchase_saved_cart_after_topup(
|
||||
db,
|
||||
user,
|
||||
bot=getattr(self, "bot", None),
|
||||
bot=bot_instance,
|
||||
)
|
||||
except Exception as auto_error:
|
||||
logger.error(
|
||||
@@ -318,17 +334,14 @@ class CryptoBotPaymentMixin:
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
if has_saved_cart and getattr(self, "bot", None):
|
||||
# Если у пользователя есть сохраненная корзина,
|
||||
# отправляем ему уведомление с кнопкой вернуться к оформлению
|
||||
if has_saved_cart and bot_instance:
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
|
||||
texts = get_texts(user.language)
|
||||
cart_message = texts.BALANCE_TOPUP_CART_REMINDER_DETAILED.format(
|
||||
total_amount=settings.format_price(payment.amount_kopeks)
|
||||
)
|
||||
|
||||
# Создаем клавиатуру с кнопками
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
@@ -343,22 +356,35 @@ class CryptoBotPaymentMixin:
|
||||
callback_data="back_to_menu"
|
||||
)]
|
||||
])
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n"
|
||||
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
|
||||
f"Обязательно активируйте подписку отдельно!\n\n"
|
||||
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
|
||||
f"подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}",
|
||||
reply_markup=keyboard
|
||||
|
||||
saved_cart_notification = _SavedCartNotificationPayload(
|
||||
telegram_id=user.telegram_id,
|
||||
text=(
|
||||
f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n"
|
||||
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
|
||||
f"Обязательно активируйте подписку отдельно!\n\n"
|
||||
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
|
||||
f"подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}"
|
||||
),
|
||||
reply_markup=keyboard,
|
||||
user_id=user.id,
|
||||
)
|
||||
logger.info(
|
||||
"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s",
|
||||
user.id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка при работе с сохраненной корзиной для пользователя %s: %s",
|
||||
user.id,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if admin_notification:
|
||||
await self._deliver_admin_topup_notification(admin_notification)
|
||||
|
||||
if user_notification and bot_instance:
|
||||
await self._deliver_user_topup_notification(user_notification)
|
||||
|
||||
if saved_cart_notification and bot_instance:
|
||||
await self._deliver_saved_cart_reminder(saved_cart_notification)
|
||||
|
||||
return True
|
||||
|
||||
@@ -368,6 +394,116 @@ class CryptoBotPaymentMixin:
|
||||
)
|
||||
return False
|
||||
|
||||
async def _deliver_admin_topup_notification(
|
||||
self, context: _AdminNotificationContext
|
||||
) -> None:
|
||||
bot_instance = getattr(self, "bot", None)
|
||||
if not bot_instance:
|
||||
return
|
||||
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.database.crud.transaction import get_transaction_by_id
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Не удалось импортировать зависимости для админ-уведомления CryptoBot: %s",
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
return
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
user = await get_user_by_id(session, context.user_id)
|
||||
transaction = await get_transaction_by_id(session, context.transaction_id)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка загрузки данных для админ-уведомления CryptoBot: %s",
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
await session.rollback()
|
||||
return
|
||||
|
||||
if not user or not transaction:
|
||||
logger.warning(
|
||||
"Пропущена отправка админ-уведомления CryptoBot: user=%s transaction=%s",
|
||||
bool(user),
|
||||
bool(transaction),
|
||||
)
|
||||
return
|
||||
|
||||
notification_service = AdminNotificationService(bot_instance)
|
||||
try:
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user,
|
||||
transaction,
|
||||
context.old_balance,
|
||||
topup_status=context.topup_status,
|
||||
referrer_info=context.referrer_info,
|
||||
subscription=getattr(user, "subscription", None),
|
||||
promo_group=getattr(user, "promo_group", None),
|
||||
db=session,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки админ-уведомления о пополнении CryptoBot: %s",
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
async def _deliver_user_topup_notification(
|
||||
self, payload: _UserNotificationPayload
|
||||
) -> None:
|
||||
bot_instance = getattr(self, "bot", None)
|
||||
if not bot_instance:
|
||||
return
|
||||
|
||||
try:
|
||||
await bot_instance.send_message(
|
||||
payload.telegram_id,
|
||||
payload.text,
|
||||
parse_mode=payload.parse_mode,
|
||||
reply_markup=payload.reply_markup,
|
||||
)
|
||||
logger.info(
|
||||
"✅ Отправлено уведомление пользователю %s о пополнении на %s₽ (%s)",
|
||||
payload.telegram_id,
|
||||
f"{payload.amount_rubles:.2f}",
|
||||
payload.asset,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления о пополнении CryptoBot: %s",
|
||||
error,
|
||||
)
|
||||
|
||||
async def _deliver_saved_cart_reminder(
|
||||
self, payload: _SavedCartNotificationPayload
|
||||
) -> None:
|
||||
bot_instance = getattr(self, "bot", None)
|
||||
if not bot_instance:
|
||||
return
|
||||
|
||||
try:
|
||||
await bot_instance.send_message(
|
||||
chat_id=payload.telegram_id,
|
||||
text=payload.text,
|
||||
reply_markup=payload.reply_markup,
|
||||
)
|
||||
logger.info(
|
||||
"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s",
|
||||
payload.user_id,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления о сохраненной корзине для пользователя %s: %s",
|
||||
payload.user_id,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
async def get_cryptobot_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
|
||||
+178
-42
@@ -22,7 +22,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Pal24PaymentMixin:
|
||||
"""Mixin с созданием счетов Pal24, обработкой postback и запросом статуса."""
|
||||
"""Mixin с созданием счетов Pal24, обработкой callback и запросом статуса."""
|
||||
|
||||
async def create_pal24_payment(
|
||||
self,
|
||||
@@ -197,12 +197,12 @@ class Pal24PaymentMixin:
|
||||
"payment_url": primary_link,
|
||||
}
|
||||
|
||||
async def process_pal24_postback(
|
||||
async def process_pal24_callback(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
postback: Dict[str, Any],
|
||||
callback: Dict[str, Any],
|
||||
) -> bool:
|
||||
"""Обрабатывает postback от Pal24 и начисляет баланс при успехе."""
|
||||
"""Обрабатывает callback от Pal24 и начисляет баланс при успехе."""
|
||||
try:
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
|
||||
@@ -213,26 +213,26 @@ class Pal24PaymentMixin:
|
||||
return None
|
||||
|
||||
payment_id = _first_non_empty(
|
||||
postback.get("id"),
|
||||
postback.get("TrsId"),
|
||||
postback.get("TrsID"),
|
||||
callback.get("id"),
|
||||
callback.get("TrsId"),
|
||||
callback.get("TrsID"),
|
||||
)
|
||||
bill_id = _first_non_empty(
|
||||
postback.get("bill_id"),
|
||||
postback.get("billId"),
|
||||
postback.get("BillId"),
|
||||
postback.get("BillID"),
|
||||
callback.get("bill_id"),
|
||||
callback.get("billId"),
|
||||
callback.get("BillId"),
|
||||
callback.get("BillID"),
|
||||
)
|
||||
order_id = _first_non_empty(
|
||||
postback.get("order_id"),
|
||||
postback.get("orderId"),
|
||||
postback.get("InvId"),
|
||||
postback.get("InvID"),
|
||||
callback.get("order_id"),
|
||||
callback.get("orderId"),
|
||||
callback.get("InvId"),
|
||||
callback.get("InvID"),
|
||||
)
|
||||
status = (postback.get("status") or postback.get("Status") or "").upper()
|
||||
status = (callback.get("status") or callback.get("Status") or "").upper()
|
||||
|
||||
if not bill_id and not order_id:
|
||||
logger.error("Pal24 postback без идентификаторов: %s", postback)
|
||||
logger.error("Pal24 callback без идентификаторов: %s", callback)
|
||||
return False
|
||||
|
||||
payment = None
|
||||
@@ -250,57 +250,65 @@ class Pal24PaymentMixin:
|
||||
return True
|
||||
|
||||
if status in {"PAID", "SUCCESS", "OVERPAID"}:
|
||||
metadata = getattr(payment, "metadata_json", {}) or {}
|
||||
if not isinstance(metadata, dict):
|
||||
metadata = {}
|
||||
|
||||
payment = await payment_module.update_pal24_payment_status(
|
||||
db,
|
||||
payment,
|
||||
status=status,
|
||||
is_paid=True,
|
||||
paid_at=datetime.utcnow(),
|
||||
postback_payload=postback,
|
||||
callback_payload=callback,
|
||||
payment_id=payment_id,
|
||||
payment_status=postback.get("Status") or status,
|
||||
payment_status=callback.get("Status") or status,
|
||||
payment_method=(
|
||||
postback.get("payment_method")
|
||||
or postback.get("PaymentMethod")
|
||||
or (payment.metadata_json or {}).get("selected_method")
|
||||
callback.get("payment_method")
|
||||
or callback.get("PaymentMethod")
|
||||
or metadata.get("selected_method")
|
||||
or getattr(payment, "payment_method", None)
|
||||
),
|
||||
balance_amount=postback.get("BalanceAmount")
|
||||
or postback.get("balance_amount"),
|
||||
balance_currency=postback.get("BalanceCurrency")
|
||||
or postback.get("balance_currency"),
|
||||
payer_account=postback.get("AccountNumber")
|
||||
or postback.get("account")
|
||||
or postback.get("Account"),
|
||||
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"),
|
||||
)
|
||||
|
||||
return await self._finalize_pal24_payment(
|
||||
db,
|
||||
payment,
|
||||
payment_id=payment_id,
|
||||
trigger="postback",
|
||||
trigger="callback",
|
||||
)
|
||||
|
||||
metadata = getattr(payment, "metadata_json", {}) or {}
|
||||
if not isinstance(metadata, dict):
|
||||
metadata = {}
|
||||
|
||||
await payment_module.update_pal24_payment_status(
|
||||
db,
|
||||
payment,
|
||||
status=status or "UNKNOWN",
|
||||
is_paid=False,
|
||||
postback_payload=postback,
|
||||
callback_payload=callback,
|
||||
payment_id=payment_id,
|
||||
payment_status=postback.get("Status") or status,
|
||||
payment_status=callback.get("Status") or status,
|
||||
payment_method=(
|
||||
postback.get("payment_method")
|
||||
or postback.get("PaymentMethod")
|
||||
callback.get("payment_method")
|
||||
or callback.get("PaymentMethod")
|
||||
or getattr(payment, "payment_method", None)
|
||||
),
|
||||
balance_amount=postback.get("BalanceAmount")
|
||||
or postback.get("balance_amount"),
|
||||
balance_currency=postback.get("BalanceCurrency")
|
||||
or postback.get("balance_currency"),
|
||||
payer_account=postback.get("AccountNumber")
|
||||
or postback.get("account")
|
||||
or postback.get("Account"),
|
||||
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"),
|
||||
)
|
||||
logger.info(
|
||||
"Обновили Pal24 платеж %s до статуса %s",
|
||||
@@ -310,7 +318,7 @@ class Pal24PaymentMixin:
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error("Ошибка обработки Pal24 postback: %s", error, exc_info=True)
|
||||
logger.error("Ошибка обработки Pal24 callback: %s", error, exc_info=True)
|
||||
return False
|
||||
|
||||
async def _finalize_pal24_payment(
|
||||
@@ -685,12 +693,34 @@ class Pal24PaymentMixin:
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
links_map, selected_method = self._build_links_map(payment, remote_payloads)
|
||||
primary_url = (
|
||||
links_map.get(selected_method)
|
||||
or links_map.get("sbp")
|
||||
or links_map.get("page")
|
||||
or links_map.get("card")
|
||||
)
|
||||
secondary_url = (
|
||||
links_map.get("page")
|
||||
or links_map.get("card")
|
||||
or links_map.get("sbp")
|
||||
)
|
||||
|
||||
return {
|
||||
"payment": payment,
|
||||
"status": payment.status,
|
||||
"is_paid": payment.is_paid,
|
||||
"remote_status": remote_status_for_return,
|
||||
"remote_data": remote_data,
|
||||
"links": links_map or None,
|
||||
"primary_url": primary_url,
|
||||
"secondary_url": secondary_url,
|
||||
"sbp_url": links_map.get("sbp"),
|
||||
"card_url": links_map.get("card"),
|
||||
"link_page_url": links_map.get("page")
|
||||
or getattr(payment, "link_page_url", None),
|
||||
"link_url": getattr(payment, "link_url", None),
|
||||
"selected_method": selected_method,
|
||||
}
|
||||
|
||||
except Exception as error:
|
||||
@@ -841,6 +871,112 @@ class Pal24PaymentMixin:
|
||||
normalized = payment_method.strip().lower()
|
||||
return mapping.get(normalized, "sbp")
|
||||
|
||||
@staticmethod
|
||||
def _pick_first(mapping: Dict[str, Any], *keys: str) -> Optional[str]:
|
||||
for key in keys:
|
||||
value = mapping.get(key)
|
||||
if value:
|
||||
return str(value)
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _build_links_map(
|
||||
cls,
|
||||
payment: Any,
|
||||
remote_payloads: Dict[str, Any],
|
||||
) -> tuple[Dict[str, str], str]:
|
||||
links: Dict[str, str] = {}
|
||||
|
||||
metadata = getattr(payment, "metadata_json", {}) or {}
|
||||
if not isinstance(metadata, dict):
|
||||
metadata = {}
|
||||
|
||||
if metadata:
|
||||
links_meta = metadata.get("links")
|
||||
if isinstance(links_meta, dict):
|
||||
for key, value in links_meta.items():
|
||||
if value:
|
||||
links[key] = str(value)
|
||||
|
||||
selected_method = cls._normalize_payment_method(
|
||||
(metadata.get("selected_method") if isinstance(metadata, dict) else None)
|
||||
or getattr(payment, "payment_method", None)
|
||||
)
|
||||
|
||||
def _visit(value: Any) -> List[Dict[str, Any]]:
|
||||
stack: List[Any] = [value]
|
||||
result: List[Dict[str, Any]] = []
|
||||
while stack:
|
||||
current = stack.pop()
|
||||
if isinstance(current, dict):
|
||||
result.append(current)
|
||||
stack.extend(current.values())
|
||||
elif isinstance(current, list):
|
||||
stack.extend(current)
|
||||
return result
|
||||
|
||||
payload_sources: List[Any] = []
|
||||
if metadata:
|
||||
payload_sources.append(metadata.get("raw_response"))
|
||||
payload_sources.append(getattr(payment, "callback_payload", None))
|
||||
payload_sources.extend(remote_payloads.values())
|
||||
|
||||
sbp_keys = (
|
||||
"transfer_url",
|
||||
"transferUrl",
|
||||
"transfer_link",
|
||||
"transferLink",
|
||||
"transfer",
|
||||
"sbp_url",
|
||||
"sbpUrl",
|
||||
"sbp_link",
|
||||
"sbpLink",
|
||||
)
|
||||
card_keys = (
|
||||
"link_url",
|
||||
"linkUrl",
|
||||
"link",
|
||||
"card_url",
|
||||
"cardUrl",
|
||||
"card_link",
|
||||
"cardLink",
|
||||
"payment_url",
|
||||
"paymentUrl",
|
||||
"url",
|
||||
)
|
||||
page_keys = (
|
||||
"link_page_url",
|
||||
"linkPageUrl",
|
||||
"page_url",
|
||||
"pageUrl",
|
||||
)
|
||||
|
||||
for source in payload_sources:
|
||||
if not source:
|
||||
continue
|
||||
for candidate in _visit(source):
|
||||
sbp_url = cls._pick_first(candidate, *sbp_keys)
|
||||
if sbp_url and "sbp" not in links:
|
||||
links["sbp"] = sbp_url
|
||||
card_url = cls._pick_first(candidate, *card_keys)
|
||||
if card_url and "card" not in links:
|
||||
links["card"] = card_url
|
||||
page_url = cls._pick_first(candidate, *page_keys)
|
||||
if page_url and "page" not in links:
|
||||
links["page"] = page_url
|
||||
|
||||
if getattr(payment, "link_page_url", None):
|
||||
links.setdefault("page", str(payment.link_page_url))
|
||||
|
||||
if getattr(payment, "link_url", None):
|
||||
link_url_value = str(payment.link_url)
|
||||
if selected_method == "card":
|
||||
links.setdefault("card", link_url_value)
|
||||
else:
|
||||
links.setdefault("sbp", link_url_value)
|
||||
|
||||
return links, selected_method
|
||||
|
||||
@staticmethod
|
||||
def _map_api_payment_method(normalized_payment_method: str) -> Optional[str]:
|
||||
"""Преобразует нормализованный метод оплаты в значение для Pal24 API."""
|
||||
|
||||
@@ -382,7 +382,47 @@ class YooKassaPaymentMixin:
|
||||
from sqlalchemy import select
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
|
||||
payment_description = getattr(payment, "description", "YooKassa платеж")
|
||||
# Проверяем, не обрабатывается ли уже этот платеж (защита от дублирования)
|
||||
existing_transaction = await payment_module.get_transaction_by_external_id( # type: ignore[attr-defined]
|
||||
db,
|
||||
payment.yookassa_payment_id,
|
||||
PaymentMethod.YOOKASSA,
|
||||
)
|
||||
|
||||
if existing_transaction:
|
||||
# Если транзакция уже существует, просто завершаем обработку
|
||||
logger.info(
|
||||
"Платеж YooKassa %s уже был обработан транзакцией %s. Пропускаем повторную обработку.",
|
||||
payment.yookassa_payment_id,
|
||||
existing_transaction.id,
|
||||
)
|
||||
|
||||
# Убедимся, что платеж связан с транзакцией
|
||||
if not getattr(payment, "transaction_id", None):
|
||||
try:
|
||||
linked_payment = await payment_module.link_yookassa_payment_to_transaction( # type: ignore[attr-defined]
|
||||
db,
|
||||
payment.yookassa_payment_id,
|
||||
existing_transaction.id,
|
||||
)
|
||||
if linked_payment:
|
||||
payment.transaction_id = getattr(
|
||||
linked_payment,
|
||||
"transaction_id",
|
||||
existing_transaction.id,
|
||||
)
|
||||
if hasattr(linked_payment, "transaction"):
|
||||
payment.transaction = linked_payment.transaction
|
||||
except Exception as link_error: # pragma: no cover - защитный лог
|
||||
logger.warning(
|
||||
"Не удалось привязать платеж YooKassa %s к существующей транзакции %s: %s",
|
||||
payment.yookassa_payment_id,
|
||||
existing_transaction.id,
|
||||
link_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
payment_metadata: Dict[str, Any] = {}
|
||||
try:
|
||||
@@ -397,6 +437,84 @@ class YooKassaPaymentMixin:
|
||||
except Exception as parse_error:
|
||||
logger.error(f"Ошибка парсинга метаданных платежа: {parse_error}")
|
||||
|
||||
processing_completed = bool(payment_metadata.get("processing_completed"))
|
||||
|
||||
transaction = None
|
||||
|
||||
existing_transaction_id = getattr(payment, "transaction_id", None)
|
||||
if existing_transaction_id:
|
||||
try:
|
||||
from app.database.crud.transaction import get_transaction_by_id
|
||||
|
||||
transaction = await get_transaction_by_id(db, existing_transaction_id)
|
||||
except Exception as fetch_error: # pragma: no cover - диагностический лог
|
||||
logger.warning(
|
||||
"Не удалось получить транзакцию %s для платежа YooKassa %s: %s",
|
||||
existing_transaction_id,
|
||||
payment.yookassa_payment_id,
|
||||
fetch_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if transaction and processing_completed:
|
||||
logger.info(
|
||||
"Пропускаем повторную обработку платежа YooKassa %s: транзакция %s уже завершила начисление.",
|
||||
payment.yookassa_payment_id,
|
||||
existing_transaction_id,
|
||||
)
|
||||
return True
|
||||
|
||||
if transaction:
|
||||
logger.info(
|
||||
"Транзакция %s для платежа YooKassa %s найдена, но обработка ранее не была завершена — повторяем критические шаги.",
|
||||
existing_transaction_id,
|
||||
payment.yookassa_payment_id,
|
||||
)
|
||||
|
||||
if transaction is None:
|
||||
existing_transaction = await payment_module.get_transaction_by_external_id( # type: ignore[attr-defined]
|
||||
db,
|
||||
payment.yookassa_payment_id,
|
||||
PaymentMethod.YOOKASSA,
|
||||
)
|
||||
|
||||
if existing_transaction:
|
||||
# Если транзакция уже существует, пропускаем обработку
|
||||
logger.info(
|
||||
"Платеж YooKassa %s уже был обработан транзакцией %s. Пропускаем повторную обработку.",
|
||||
payment.yookassa_payment_id,
|
||||
existing_transaction.id,
|
||||
)
|
||||
|
||||
# Убедимся, что платеж связан с транзакцией
|
||||
if not getattr(payment, "transaction_id", None):
|
||||
try:
|
||||
linked_payment = await payment_module.link_yookassa_payment_to_transaction( # type: ignore[attr-defined]
|
||||
db,
|
||||
payment.yookassa_payment_id,
|
||||
existing_transaction.id,
|
||||
)
|
||||
if linked_payment:
|
||||
payment.transaction_id = getattr(
|
||||
linked_payment,
|
||||
"transaction_id",
|
||||
existing_transaction.id,
|
||||
)
|
||||
if hasattr(linked_payment, "transaction"):
|
||||
payment.transaction = linked_payment.transaction
|
||||
except Exception as link_error: # pragma: no cover - защитный лог
|
||||
logger.warning(
|
||||
"Не удалось привязать платеж YooKassa %s к существующей транзакции %s: %s",
|
||||
payment.yookassa_payment_id,
|
||||
existing_transaction.id,
|
||||
link_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
payment_description = getattr(payment, "description", "YooKassa платеж")
|
||||
|
||||
payment_purpose = payment_metadata.get("payment_purpose", "")
|
||||
is_simple_subscription = payment_purpose == "simple_subscription_purchase"
|
||||
|
||||
@@ -411,27 +529,32 @@ class YooKassaPaymentMixin:
|
||||
else f"Пополнение через YooKassa: {payment_description}"
|
||||
)
|
||||
|
||||
transaction = await payment_module.create_transaction(
|
||||
db=db,
|
||||
user_id=payment.user_id,
|
||||
type=transaction_type,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
description=transaction_description,
|
||||
payment_method=PaymentMethod.YOOKASSA,
|
||||
external_id=payment.yookassa_payment_id,
|
||||
is_completed=True,
|
||||
)
|
||||
if transaction is None:
|
||||
transaction = await payment_module.create_transaction(
|
||||
db=db,
|
||||
user_id=payment.user_id,
|
||||
type=transaction_type,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
description=transaction_description,
|
||||
payment_method=PaymentMethod.YOOKASSA,
|
||||
external_id=payment.yookassa_payment_id,
|
||||
is_completed=True,
|
||||
)
|
||||
|
||||
linked_payment = await payment_module.link_yookassa_payment_to_transaction(
|
||||
db,
|
||||
payment.yookassa_payment_id,
|
||||
transaction.id,
|
||||
)
|
||||
if not getattr(payment, "transaction_id", None):
|
||||
linked_payment = await payment_module.link_yookassa_payment_to_transaction(
|
||||
db,
|
||||
payment.yookassa_payment_id,
|
||||
transaction.id,
|
||||
)
|
||||
|
||||
if linked_payment:
|
||||
payment.transaction_id = getattr(linked_payment, "transaction_id", transaction.id)
|
||||
if hasattr(linked_payment, "transaction"):
|
||||
payment.transaction = linked_payment.transaction
|
||||
if linked_payment:
|
||||
payment.transaction_id = getattr(linked_payment, "transaction_id", transaction.id)
|
||||
if hasattr(linked_payment, "transaction"):
|
||||
payment.transaction = linked_payment.transaction
|
||||
|
||||
critical_flow_completed = False
|
||||
processing_marked = False
|
||||
|
||||
user = await payment_module.get_user_by_id(db, payment.user_id)
|
||||
if user:
|
||||
@@ -473,6 +596,14 @@ class YooKassaPaymentMixin:
|
||||
"🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
|
||||
)
|
||||
|
||||
payment_metadata = await self._mark_yookassa_payment_processing_completed(
|
||||
db,
|
||||
payment,
|
||||
payment_metadata,
|
||||
commit=False,
|
||||
)
|
||||
processing_marked = True
|
||||
|
||||
await db.commit()
|
||||
|
||||
try:
|
||||
@@ -748,6 +879,31 @@ class YooKassaPaymentMixin:
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка активации подписки для пользователя {user.id}: {e}", exc_info=True)
|
||||
|
||||
if not processing_marked:
|
||||
payment_metadata = await self._mark_yookassa_payment_processing_completed(
|
||||
db,
|
||||
payment,
|
||||
payment_metadata,
|
||||
commit=True,
|
||||
)
|
||||
processing_marked = True
|
||||
|
||||
critical_flow_completed = True
|
||||
else:
|
||||
logger.warning(
|
||||
"Пользователь %s для платежа YooKassa %s не найден — начисление баланса невозможно",
|
||||
payment.user_id,
|
||||
payment.yookassa_payment_id,
|
||||
)
|
||||
|
||||
if critical_flow_completed and not processing_marked:
|
||||
payment_metadata = await self._mark_yookassa_payment_processing_completed(
|
||||
db,
|
||||
payment,
|
||||
payment_metadata,
|
||||
commit=True,
|
||||
)
|
||||
|
||||
if is_simple_subscription:
|
||||
logger.info(
|
||||
"Успешно обработан платеж YooKassa %s как покупка подписки: пользователь %s, сумма %s₽",
|
||||
@@ -773,6 +929,46 @@ class YooKassaPaymentMixin:
|
||||
)
|
||||
return False
|
||||
|
||||
async def _mark_yookassa_payment_processing_completed(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payment: "YooKassaPayment",
|
||||
payment_metadata: Dict[str, Any],
|
||||
*,
|
||||
commit: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Отмечает платёж как полностью обработанный, чтобы избежать повторного начисления."""
|
||||
|
||||
if payment_metadata.get("processing_completed"):
|
||||
return payment_metadata
|
||||
|
||||
updated_metadata = dict(payment_metadata)
|
||||
updated_metadata["processing_completed"] = True
|
||||
|
||||
try:
|
||||
from sqlalchemy import update
|
||||
from app.database.models import YooKassaPayment as YooKassaPaymentModel
|
||||
|
||||
await db.execute(
|
||||
update(YooKassaPaymentModel)
|
||||
.where(YooKassaPaymentModel.id == payment.id)
|
||||
.values(metadata_json=updated_metadata, updated_at=datetime.utcnow())
|
||||
)
|
||||
if commit:
|
||||
await db.commit()
|
||||
else:
|
||||
await db.flush()
|
||||
payment.metadata_json = updated_metadata
|
||||
except Exception as mark_error: # pragma: no cover - защитный лог
|
||||
logger.warning(
|
||||
"Не удалось отметить платеж YooKassa %s как завершенный: %s",
|
||||
payment.yookassa_payment_id,
|
||||
mark_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return updated_metadata
|
||||
|
||||
async def process_yookassa_webhook(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
|
||||
@@ -90,7 +90,16 @@ def get_available_payment_methods() -> List[Dict[str, str]]:
|
||||
"description": "через Heleket",
|
||||
"callback": "topup_heleket"
|
||||
})
|
||||
|
||||
|
||||
if settings.is_platega_enabled() and settings.get_platega_active_methods():
|
||||
methods.append({
|
||||
"id": "platega",
|
||||
"name": "Банковская карта",
|
||||
"icon": "💳",
|
||||
"description": "через Platega (карты + СБП)",
|
||||
"callback": "topup_platega",
|
||||
})
|
||||
|
||||
# Поддержка всегда доступна
|
||||
methods.append({
|
||||
"id": "support",
|
||||
@@ -174,6 +183,8 @@ def is_payment_method_available(method_id: str) -> bool:
|
||||
return settings.is_cryptobot_enabled()
|
||||
elif method_id == "heleket":
|
||||
return settings.is_heleket_enabled()
|
||||
elif method_id == "platega":
|
||||
return settings.is_platega_enabled() and bool(settings.get_platega_active_methods())
|
||||
elif method_id == "support":
|
||||
return True # Поддержка всегда доступна
|
||||
else:
|
||||
@@ -192,6 +203,7 @@ def get_payment_method_status() -> Dict[str, bool]:
|
||||
"pal24": settings.is_pal24_enabled(),
|
||||
"cryptobot": settings.is_cryptobot_enabled(),
|
||||
"heleket": settings.is_heleket_enabled(),
|
||||
"platega": settings.is_platega_enabled() and bool(settings.get_platega_active_methods()),
|
||||
"support": True
|
||||
}
|
||||
|
||||
@@ -216,4 +228,6 @@ def get_enabled_payment_methods_count() -> int:
|
||||
count += 1
|
||||
if settings.is_heleket_enabled():
|
||||
count += 1
|
||||
if settings.is_platega_enabled() and settings.get_platega_active_methods():
|
||||
count += 1
|
||||
return count
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from fastapi import APIRouter, Security
|
||||
|
||||
from app.config import settings
|
||||
from app.database import db_manager, get_pool_metrics
|
||||
from app.services.version_service import version_service
|
||||
|
||||
from ..dependencies import require_api_token
|
||||
@@ -24,3 +25,17 @@ async def health_check(_: object = Security(require_api_token)) -> HealthCheckRe
|
||||
webhooks=bool(settings.WEBHOOK_URL),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/health/database", tags=["health"])
|
||||
async def database_health(_: object = Security(require_api_token)) -> dict:
|
||||
"""Детальная информация о состоянии базы данных."""
|
||||
|
||||
return await db_manager.health_check()
|
||||
|
||||
|
||||
@router.get("/metrics/pool", tags=["health"])
|
||||
async def pool_metrics(_: object = Security(require_api_token)) -> dict:
|
||||
"""Метрики пула подключений к базе данных."""
|
||||
|
||||
return await get_pool_metrics()
|
||||
|
||||
@@ -1537,6 +1537,8 @@ async def _resolve_pal24_payment_status(
|
||||
if remote_status:
|
||||
message = f"Status: {remote_status}"
|
||||
|
||||
links_info = status_info.get("links") if status_info else {}
|
||||
|
||||
return MiniAppPaymentStatusResult(
|
||||
method="pal24",
|
||||
status=status,
|
||||
@@ -1556,6 +1558,14 @@ async def _resolve_pal24_payment_status(
|
||||
"payment_method": getattr(payment, "payment_method", None),
|
||||
"payload": query.payload,
|
||||
"started_at": query.started_at,
|
||||
"links": links_info or None,
|
||||
"sbp_url": status_info.get("sbp_url") if status_info else None,
|
||||
"card_url": status_info.get("card_url") if status_info else None,
|
||||
"link_url": status_info.get("link_url") if status_info else None,
|
||||
"link_page_url": status_info.get("link_page_url") if status_info else None,
|
||||
"primary_url": status_info.get("primary_url") if status_info else None,
|
||||
"secondary_url": status_info.get("secondary_url") if status_info else None,
|
||||
"selected_method": status_info.get("selected_method") if status_info else None,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -349,6 +349,7 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
header_ip_candidates = yookassa_webhook_module.collect_yookassa_ip_candidates(
|
||||
request.headers.get("X-Forwarded-For"),
|
||||
request.headers.get("X-Real-IP"),
|
||||
request.headers.get("Cf-Connecting-Ip"),
|
||||
)
|
||||
remote_ip = request.client.host if request.client else None
|
||||
client_ip = yookassa_webhook_module.resolve_yookassa_ip(
|
||||
@@ -578,7 +579,7 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
)
|
||||
|
||||
try:
|
||||
parsed_payload = pal24_service.parse_postback(payload)
|
||||
parsed_payload = pal24_service.parse_callback(payload)
|
||||
except Pal24APIError as error:
|
||||
return JSONResponse(
|
||||
{"status": "error", "reason": str(error)},
|
||||
@@ -588,7 +589,7 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
success = await _process_payment_service_callback(
|
||||
payment_service,
|
||||
parsed_payload,
|
||||
"process_pal24_postback",
|
||||
"process_pal24_callback",
|
||||
)
|
||||
if success:
|
||||
return JSONResponse({"status": "ok"})
|
||||
|
||||
@@ -170,8 +170,8 @@
|
||||
- `app/external/pal24_client.py` — Async client for PayPalych (Pal24) API.
|
||||
Классы: `Pal24APIError` — Base error for Pal24 API operations., `Pal24Response` (2 методов) — Wrapper for Pal24 API responses., `Pal24Client` (5 методов) — Async client implementing PayPalych API methods.
|
||||
Функции: нет
|
||||
- `app/external/pal24_webhook.py` — Flask webhook server for PayPalych postbacks.
|
||||
Классы: `Pal24WebhookServer` (3 методов) — Threaded Flask server for Pal24 postbacks.
|
||||
- `app/external/pal24_webhook.py` — Flask webhook server for PayPalych callbacks.
|
||||
Классы: `Pal24WebhookServer` (3 методов) — Threaded Flask server for Pal24 callbacks.
|
||||
Функции: `_normalize_payload`, `create_pal24_flask_app`
|
||||
- `app/external/remnawave_api.py` — Python-модуль
|
||||
Классы: `UserStatus`, `TrafficLimitStrategy`, `RemnaWaveUser`, `RemnaWaveInternalSquad`, `RemnaWaveNode`, `SubscriptionInfo`, `RemnaWaveAPIError` (1 методов), `RemnaWaveAPI` (8 методов)
|
||||
@@ -731,7 +731,7 @@
|
||||
Функции: `anyio_backend`, `_enable_service`, `test_is_configured`, `test_format_and_signature`
|
||||
- `tests/services/test_pal24_service_adapter.py` — Тесты Pal24Service и вспомогательных функций.
|
||||
Классы: `StubPal24Client` (1 методов)
|
||||
Функции: `_enable_pal24`, `anyio_backend`, `test_parse_postback_success`, `test_parse_postback_missing_fields`, `test_convert_to_kopeks_and_expiration`
|
||||
Функции: `_enable_pal24`, `anyio_backend`, `test_parse_callback_success`, `test_parse_callback_missing_fields`, `test_convert_to_kopeks_and_expiration`
|
||||
- `tests/services/test_payment_service_cryptobot.py` — Тесты сценариев CryptoBot в PaymentService.
|
||||
Классы: `DummySession` (2 методов), `DummyLocalPayment` (1 методов), `StubCryptoBotService` (1 методов)
|
||||
Функции: `anyio_backend`, `_make_service`
|
||||
|
||||
@@ -14,6 +14,7 @@ redis==5.0.1
|
||||
PyYAML==6.0.2
|
||||
fastapi==0.115.6
|
||||
uvicorn==0.32.1
|
||||
python-multipart==0.0.9
|
||||
|
||||
# YooKassa SDK
|
||||
yookassa==3.7.0
|
||||
|
||||
+27
@@ -32,6 +32,7 @@ def _build_headers(**overrides: str) -> dict[str, str]:
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Forwarded-For": ALLOWED_IP,
|
||||
"Cf-Connecting-Ip": ALLOWED_IP,
|
||||
}
|
||||
headers.update(overrides)
|
||||
return headers
|
||||
@@ -138,6 +139,32 @@ async def test_handle_webhook_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
process_mock.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_webhook_trusts_cf_connecting_ip(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_patch_get_db(monkeypatch)
|
||||
|
||||
process_mock = AsyncMock(return_value=True)
|
||||
service = SimpleNamespace(process_yookassa_webhook=process_mock)
|
||||
|
||||
app = create_yookassa_webhook_app(service)
|
||||
async with TestClient(TestServer(app)) as client:
|
||||
payload = {"event": "payment.succeeded"}
|
||||
body = json.dumps(payload, ensure_ascii=False)
|
||||
headers = _build_headers()
|
||||
headers.pop("X-Forwarded-For")
|
||||
response = await client.post(
|
||||
settings.YOOKASSA_WEBHOOK_PATH,
|
||||
data=body.encode("utf-8"),
|
||||
headers=headers,
|
||||
)
|
||||
status = response.status
|
||||
text = await response.text()
|
||||
|
||||
assert status == 200
|
||||
assert text == "OK"
|
||||
process_mock.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_webhook_with_optional_signature(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_patch_get_db(monkeypatch)
|
||||
|
||||
@@ -110,7 +110,7 @@ async def test_get_bill_payments(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
assert result == {"id": "BILL42", "payments": [{"id": "PAY-1"}]}
|
||||
|
||||
|
||||
def test_parse_postback_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_parse_callback_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_enable_pal24(monkeypatch)
|
||||
sig = Pal24Client.calculate_signature("100.00", "INV1", api_token="sigsecret")
|
||||
payload = {
|
||||
@@ -119,14 +119,14 @@ def test_parse_postback_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"Status": "SUCCESS",
|
||||
"SignatureValue": sig,
|
||||
}
|
||||
result = Pal24Service.parse_postback(payload)
|
||||
result = Pal24Service.parse_callback(payload)
|
||||
assert result["InvId"] == "INV1"
|
||||
|
||||
|
||||
def test_parse_postback_missing_fields(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_parse_callback_missing_fields(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_enable_pal24(monkeypatch)
|
||||
with pytest.raises(Pal24APIError):
|
||||
Pal24Service.parse_postback({"InvId": "1"})
|
||||
Pal24Service.parse_callback({"InvId": "1"})
|
||||
|
||||
|
||||
def test_convert_to_kopeks_and_expiration() -> None:
|
||||
|
||||
@@ -17,6 +17,7 @@ if str(ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT_DIR))
|
||||
|
||||
import app.services.payment_service as payment_service_module # noqa: E402
|
||||
import app.services.payment.cryptobot as cryptobot_module # noqa: E402
|
||||
from app.services.payment_service import PaymentService # noqa: E402
|
||||
from app.database.models import PaymentMethod # noqa: E402
|
||||
from app.config import settings # noqa: E402
|
||||
@@ -283,13 +284,21 @@ async def test_process_cryptobot_webhook_success(monkeypatch: pytest.MonkeyPatch
|
||||
monkeypatch.setitem(sys.modules, "app.database.crud.cryptobot", fake_cryptobot_module)
|
||||
|
||||
transactions: list[Dict[str, Any]] = []
|
||||
created_transaction: SimpleNamespace | None = None
|
||||
|
||||
async def fake_create_transaction(db, **kwargs):
|
||||
nonlocal created_transaction
|
||||
transactions.append(kwargs)
|
||||
return SimpleNamespace(id=888, **kwargs)
|
||||
created_transaction = SimpleNamespace(id=888, **kwargs)
|
||||
return created_transaction
|
||||
|
||||
fake_transaction_module = ModuleType("app.database.crud.transaction")
|
||||
fake_transaction_module.create_transaction = fake_create_transaction
|
||||
|
||||
async def fake_get_transaction_by_id(db, transaction_id):
|
||||
return created_transaction
|
||||
|
||||
fake_transaction_module.get_transaction_by_id = fake_get_transaction_by_id
|
||||
monkeypatch.setitem(sys.modules, "app.database.crud.transaction", fake_transaction_module)
|
||||
monkeypatch.setattr(payment_service_module, "create_transaction", fake_create_transaction)
|
||||
|
||||
@@ -310,6 +319,10 @@ async def test_process_cryptobot_webhook_success(monkeypatch: pytest.MonkeyPatch
|
||||
|
||||
monkeypatch.setattr(payment_service_module, "get_user_by_id", fake_get_user_crypto)
|
||||
|
||||
fake_user_module = ModuleType("app.database.crud.user")
|
||||
fake_user_module.get_user_by_id = fake_get_user_crypto
|
||||
monkeypatch.setitem(sys.modules, "app.database.crud.user", fake_user_module)
|
||||
|
||||
referral_crypto = SimpleNamespace(process_referral_topup=AsyncMock())
|
||||
monkeypatch.setitem(sys.modules, "app.services.referral_service", referral_crypto)
|
||||
|
||||
@@ -323,6 +336,18 @@ async def test_process_cryptobot_webhook_success(monkeypatch: pytest.MonkeyPatch
|
||||
admin_calls.append((args, kwargs))
|
||||
|
||||
monkeypatch.setitem(sys.modules, "app.services.admin_notification_service", SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminService2(bot)))
|
||||
|
||||
class DummyAsyncSession:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
async def rollback(self): # pragma: no cover - defensive stub
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(cryptobot_module, "AsyncSessionLocal", lambda: DummyAsyncSession())
|
||||
monkeypatch.setattr(payment_service_module.currency_converter, "usd_to_rub", AsyncMock(return_value=140.0))
|
||||
monkeypatch.setattr(type(settings), "format_price", lambda self, amount: f"{amount / 100:.2f}₽", raising=False)
|
||||
service.build_topup_success_keyboard = AsyncMock(return_value=None)
|
||||
@@ -921,7 +946,7 @@ async def test_process_yookassa_webhook_missing_id(monkeypatch: pytest.MonkeyPat
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_process_pal24_postback_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def test_process_pal24_callback_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
bot = DummyBot()
|
||||
service = _make_service(bot)
|
||||
service.pal24_service = SimpleNamespace(is_configured=True)
|
||||
@@ -1044,7 +1069,7 @@ async def test_process_pal24_postback_success(monkeypatch: pytest.MonkeyPatch) -
|
||||
"TrsId": "trs-1",
|
||||
}
|
||||
|
||||
result = await service.process_pal24_postback(fake_session, payload)
|
||||
result = await service.process_pal24_callback(fake_session, payload)
|
||||
|
||||
assert result is True
|
||||
assert payment.transaction_id == 654
|
||||
@@ -1209,7 +1234,7 @@ async def test_get_pal24_payment_status_auto_finalize(monkeypatch: pytest.Monkey
|
||||
assert transactions and transactions[0]["user_id"] == 91
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_process_pal24_postback_payment_not_found(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def test_process_pal24_callback_payment_not_found(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
bot = DummyBot()
|
||||
service = _make_service(bot)
|
||||
service.pal24_service = SimpleNamespace(is_configured=True)
|
||||
@@ -1236,5 +1261,5 @@ async def test_process_pal24_postback_payment_not_found(monkeypatch: pytest.Monk
|
||||
"Status": "SUCCESS",
|
||||
}
|
||||
|
||||
result = await service.process_pal24_postback(db, payload)
|
||||
result = await service.process_pal24_callback(db, payload)
|
||||
assert result is False
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.handlers.subscription.purchase import activate_trial
|
||||
from app.services.trial_activation_service import TrialPaymentInsufficientFunds
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def trial_callback_query():
|
||||
callback = AsyncMock(spec=CallbackQuery)
|
||||
callback.message = AsyncMock(spec=Message)
|
||||
callback.message.edit_text = AsyncMock()
|
||||
callback.answer = AsyncMock()
|
||||
return callback
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def trial_user():
|
||||
user = MagicMock(spec=User)
|
||||
user.subscription = None
|
||||
user.has_had_paid_subscription = False
|
||||
user.language = "ru"
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def trial_db():
|
||||
return AsyncMock(spec=AsyncSession)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_activate_trial_uses_trial_price_for_topup_redirect(
|
||||
trial_callback_query,
|
||||
trial_user,
|
||||
trial_db,
|
||||
):
|
||||
error = TrialPaymentInsufficientFunds(required_amount=15900, balance_amount=100)
|
||||
|
||||
mock_keyboard = InlineKeyboardMarkup(inline_keyboard=[])
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.handlers.subscription.purchase.preview_trial_activation_charge",
|
||||
side_effect=error,
|
||||
),
|
||||
patch(
|
||||
"app.handlers.subscription.purchase.get_texts",
|
||||
return_value=MagicMock(
|
||||
t=lambda key, default, **kwargs: default,
|
||||
),
|
||||
),
|
||||
patch(
|
||||
"app.handlers.subscription.purchase.get_insufficient_balance_keyboard",
|
||||
return_value=mock_keyboard,
|
||||
) as insufficient_keyboard,
|
||||
):
|
||||
await activate_trial(trial_callback_query, trial_user, trial_db)
|
||||
|
||||
insufficient_keyboard.assert_called_once_with(
|
||||
trial_user.language,
|
||||
amount_kopeks=error.required_amount,
|
||||
)
|
||||
trial_callback_query.message.edit_text.assert_called_once()
|
||||
trial_callback_query.answer.assert_called_once()
|
||||
@@ -263,6 +263,37 @@ async def test_yookassa_allowed_via_forwarded_header_when_proxy(monkeypatch: pyt
|
||||
process_mock.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_yookassa_allowed_via_cf_connecting_ip(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False)
|
||||
|
||||
async def fake_get_db():
|
||||
yield SimpleNamespace()
|
||||
|
||||
monkeypatch.setattr("app.webserver.payments.get_db", fake_get_db)
|
||||
|
||||
process_mock = AsyncMock(return_value=True)
|
||||
service = SimpleNamespace(process_yookassa_webhook=process_mock)
|
||||
|
||||
router = create_payment_router(DummyBot(), service)
|
||||
assert router is not None
|
||||
|
||||
route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH)
|
||||
request = _build_request(
|
||||
settings.YOOKASSA_WEBHOOK_PATH,
|
||||
body=json.dumps({"event": "payment.succeeded"}).encode("utf-8"),
|
||||
headers={"Cf-Connecting-Ip": "185.71.76.10"},
|
||||
client_ip="172.64.223.133",
|
||||
)
|
||||
|
||||
response = await route.endpoint(request)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = json.loads(response.body.decode("utf-8"))
|
||||
assert payload["status"] == "ok"
|
||||
process_mock.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_yookassa_allowed_via_trusted_forwarded_chain(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False)
|
||||
|
||||
Reference in New Issue
Block a user