253 lines
10 KiB
Python
253 lines
10 KiB
Python
import asyncio
|
|
import logging
|
|
import json
|
|
import hashlib
|
|
import hmac
|
|
import base64
|
|
from typing import Optional, Dict, Any
|
|
from aiohttp import web
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.config import settings
|
|
from app.services.payment_service import PaymentService
|
|
from app.database.database import get_db
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class YooKassaWebhookHandler:
|
|
|
|
@staticmethod
|
|
def verify_webhook_signature(body: str, signature: str, secret: str) -> bool:
|
|
try:
|
|
signature_parts = signature.strip().split(' ')
|
|
|
|
if len(signature_parts) < 4:
|
|
logger.error(f"Неверный формат подписи YooKassa: {signature}")
|
|
return False
|
|
|
|
version = signature_parts[0]
|
|
payment_id = signature_parts[1]
|
|
timestamp = signature_parts[2]
|
|
received_signature = signature_parts[3]
|
|
|
|
if version != "v1":
|
|
logger.error(f"Неподдерживаемая версия подписи: {version}")
|
|
return False
|
|
|
|
logger.info(f"Проверка подписи v1 для платежа {payment_id}, timestamp: {timestamp}")
|
|
|
|
|
|
expected_signature_1 = hmac.new(
|
|
secret.encode('utf-8'),
|
|
body.encode('utf-8'),
|
|
hashlib.sha256
|
|
).digest()
|
|
expected_signature_1_b64 = base64.b64encode(expected_signature_1).decode('utf-8')
|
|
|
|
signed_payload_2 = f"{payment_id}.{timestamp}.{body}"
|
|
expected_signature_2 = hmac.new(
|
|
secret.encode('utf-8'),
|
|
signed_payload_2.encode('utf-8'),
|
|
hashlib.sha256
|
|
).digest()
|
|
expected_signature_2_b64 = base64.b64encode(expected_signature_2).decode('utf-8')
|
|
|
|
signed_payload_3 = f"{timestamp}.{body}"
|
|
expected_signature_3 = hmac.new(
|
|
secret.encode('utf-8'),
|
|
signed_payload_3.encode('utf-8'),
|
|
hashlib.sha256
|
|
).digest()
|
|
expected_signature_3_b64 = base64.b64encode(expected_signature_3).decode('utf-8')
|
|
|
|
logger.debug(f"Получена подпись: {received_signature}")
|
|
logger.debug(f"Ожидаемая подпись (вариант 1): {expected_signature_1_b64}")
|
|
logger.debug(f"Ожидаемая подпись (вариант 2): {expected_signature_2_b64}")
|
|
logger.debug(f"Ожидаемая подпись (вариант 3): {expected_signature_3_b64}")
|
|
|
|
is_valid = (
|
|
hmac.compare_digest(received_signature, expected_signature_1_b64) or
|
|
hmac.compare_digest(received_signature, expected_signature_2_b64) or
|
|
hmac.compare_digest(received_signature, expected_signature_3_b64)
|
|
)
|
|
|
|
if is_valid:
|
|
logger.info("✅ Подпись YooKassa webhook проверена успешно")
|
|
else:
|
|
logger.warning("⚠️ Подпись YooKassa webhook не совпадает ни с одним вариантом")
|
|
|
|
return is_valid
|
|
|
|
except Exception as e:
|
|
logger.error(f"Ошибка проверки подписи YooKassa: {e}")
|
|
return False
|
|
|
|
def __init__(self, payment_service: PaymentService):
|
|
self.payment_service = payment_service
|
|
|
|
async def handle_webhook(self, request: web.Request) -> web.Response:
|
|
|
|
try:
|
|
logger.info(f"📥 Получен YooKassa webhook: {request.method} {request.path}")
|
|
logger.info(f"📋 Headers: {dict(request.headers)}")
|
|
|
|
body = await request.text()
|
|
|
|
if not body:
|
|
logger.warning("⚠️ Получен пустой webhook от YooKassa")
|
|
return web.Response(status=400, text="Empty body")
|
|
|
|
logger.info(f"📄 Body: {body}")
|
|
|
|
signature = request.headers.get('Signature') or request.headers.get('X-YooKassa-Signature')
|
|
|
|
if settings.YOOKASSA_WEBHOOK_SECRET and signature:
|
|
logger.info(f"🔐 Получена подпись: {signature}")
|
|
|
|
if not YooKassaWebhookHandler.verify_webhook_signature(body, signature, settings.YOOKASSA_WEBHOOK_SECRET):
|
|
logger.warning("❌ Подпись не совпала, но продолжаем обработку (режим отладки)")
|
|
else:
|
|
logger.info("✅ Подпись webhook проверена успешно")
|
|
|
|
elif settings.YOOKASSA_WEBHOOK_SECRET and not signature:
|
|
logger.warning("⚠️ Webhook без подписи, но секрет настроен")
|
|
|
|
elif signature and not settings.YOOKASSA_WEBHOOK_SECRET:
|
|
logger.info("ℹ️ Подпись получена, но проверка отключена (YOOKASSA_WEBHOOK_SECRET не настроен)")
|
|
|
|
else:
|
|
logger.info("ℹ️ Проверка подписи отключена")
|
|
|
|
try:
|
|
webhook_data = json.loads(body)
|
|
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}")
|
|
|
|
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 ["payment.succeeded", "payment.waiting_for_capture"]:
|
|
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)
|
|
|
|
if success:
|
|
logger.info(f"✅ Успешно обработан webhook YooKassa: {event_type}")
|
|
return web.Response(status=200, text="OK")
|
|
else:
|
|
logger.error(f"❌ Ошибка обработки webhook YooKassa: {event_type}")
|
|
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)
|
|
|
|
logger.info(f"✅ Настроен YooKassa webhook на пути: POST {webhook_path}")
|
|
|
|
async def _get_handler(self, request: web.Request) -> web.Response:
|
|
return web.json_response({
|
|
"status": "ok",
|
|
"message": "YooKassa webhook endpoint is working",
|
|
"method": "GET",
|
|
"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,
|
|
headers={
|
|
'Access-Control-Allow-Origin': '*',
|
|
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
|
|
'Access-Control-Allow-Headers': 'Content-Type, X-YooKassa-Signature',
|
|
}
|
|
)
|
|
|
|
|
|
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",
|
|
"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,
|
|
settings.YOOKASSA_WEBHOOK_PORT,
|
|
)
|
|
logger.info(
|
|
"🎯 YooKassa webhook URL: http://%s:%s%s",
|
|
settings.YOOKASSA_WEBHOOK_HOST,
|
|
settings.YOOKASSA_WEBHOOK_PORT,
|
|
settings.YOOKASSA_WEBHOOK_PATH,
|
|
)
|
|
|
|
try:
|
|
while True:
|
|
await asyncio.sleep(1)
|
|
except asyncio.CancelledError:
|
|
logger.info("🛑 YooKassa webhook сервер получил сигнал остановки")
|
|
finally:
|
|
await site.stop()
|
|
await runner.cleanup()
|
|
logger.info("✅ YooKassa webhook сервер остановлен")
|
|
|
|
except Exception as e:
|
|
logger.error(f"❌ Ошибка запуска YooKassa webhook сервера: {e}", exc_info=True)
|
|
raise
|