fix payment
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
BrowserPool — N воркеров с очередью визитов.
|
||||
|
||||
Каждый воркер:
|
||||
1. Берёт визит из очереди
|
||||
2. Поднимает свежий браузер (через BrowserService)
|
||||
3. Делает ровно 1 визит
|
||||
4. Закрывает браузер
|
||||
5. Идёт за следующим заданием
|
||||
|
||||
Пока воркер читает страницу, другие воркеры уже могут запускать
|
||||
браузеры для следующих визитов — стартап перекрывается с чтением.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from services.browser_service import BrowserService, VisitResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _VisitRequest:
|
||||
url: str
|
||||
reading_time: Optional[int]
|
||||
future: asyncio.Future
|
||||
|
||||
|
||||
class BrowserPool:
|
||||
"""
|
||||
Пул из N воркеров. Drop-in замена для BrowserService.visit_page().
|
||||
"""
|
||||
|
||||
def __init__(self, size: int, browser_service: BrowserService):
|
||||
self._size = size
|
||||
self._bs = browser_service
|
||||
self._queue: asyncio.Queue[Optional[_VisitRequest]] = asyncio.Queue()
|
||||
self._workers: list[asyncio.Task] = []
|
||||
self._running = False
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
for i in range(self._size):
|
||||
t = asyncio.create_task(self._worker(i), name=f"browser-pool-{i}")
|
||||
self._workers.append(t)
|
||||
logger.info(f"BrowserPool started: {self._size} workers")
|
||||
|
||||
async def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
self._running = False
|
||||
for _ in self._workers:
|
||||
await self._queue.put(None) # sentinel — один на каждого воркера
|
||||
await asyncio.gather(*self._workers, return_exceptions=True)
|
||||
self._workers.clear()
|
||||
logger.info("BrowserPool stopped")
|
||||
|
||||
async def visit_page(self, url: str, reading_time: int = None) -> VisitResult:
|
||||
"""Отправляет визит в очередь и ждёт результата."""
|
||||
if not self._running:
|
||||
# Пул не запущен — fallback на прямой вызов
|
||||
return await self._bs.visit_page(url, reading_time)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
future: asyncio.Future[VisitResult] = loop.create_future()
|
||||
await self._queue.put(_VisitRequest(url=url, reading_time=reading_time, future=future))
|
||||
return await future
|
||||
|
||||
async def _worker(self, idx: int) -> None:
|
||||
logger.debug(f"Pool worker-{idx} ready")
|
||||
while True:
|
||||
item = await self._queue.get()
|
||||
if item is None: # sentinel → выход
|
||||
break
|
||||
try:
|
||||
result = await self._bs.visit_page(item.url, item.reading_time)
|
||||
if not item.future.done():
|
||||
item.future.set_result(result)
|
||||
except asyncio.CancelledError:
|
||||
if not item.future.done():
|
||||
item.future.cancel()
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Pool worker-{idx} error: {e}", exc_info=True)
|
||||
if not item.future.done():
|
||||
item.future.set_result(
|
||||
VisitResult(url=item.url, success=False, error=str(e))
|
||||
)
|
||||
finally:
|
||||
self._queue.task_done()
|
||||
logger.debug(f"Pool worker-{idx} stopped")
|
||||
|
||||
@property
|
||||
def size(self) -> int:
|
||||
return self._size
|
||||
|
||||
@property
|
||||
def queue_size(self) -> int:
|
||||
return self._queue.qsize()
|
||||
+38
-15
@@ -1,7 +1,11 @@
|
||||
"""Heleket payment integration."""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import aiohttp
|
||||
|
||||
from config.settings import settings
|
||||
@@ -9,6 +13,12 @@ from config.settings import settings
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _make_sign(body_bytes: bytes, api_key: str) -> str:
|
||||
"""Генерирует подпись запроса: md5(base64(body) + api_key)."""
|
||||
b64 = base64.b64encode(body_bytes).decode("utf-8")
|
||||
return hashlib.md5((b64 + api_key).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
class HelketPayment:
|
||||
"""Heleket payment gateway. Принимает сумму в рублях напрямую."""
|
||||
|
||||
@@ -19,6 +29,21 @@ class HelketPayment:
|
||||
self.shop_id = settings.HELEKET_SHOP_ID
|
||||
self._mock = not (self.api_key and self.shop_id)
|
||||
|
||||
def _post_headers(self, body_bytes: bytes) -> dict:
|
||||
"""Заголовки для POST-запроса с подписью."""
|
||||
return {
|
||||
"merchant": self.api_key,
|
||||
"sign": _make_sign(body_bytes, self.api_key),
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
def _get_headers(self) -> dict:
|
||||
"""Заголовки для GET-запроса (тело пустое)."""
|
||||
return {
|
||||
"merchant": self.api_key,
|
||||
"sign": _make_sign(b"", self.api_key),
|
||||
}
|
||||
|
||||
async def create_invoice(self, amount_rub: int, order_id: str) -> Optional[dict]:
|
||||
"""
|
||||
Создаёт счёт на сумму в рублях.
|
||||
@@ -32,21 +57,20 @@ class HelketPayment:
|
||||
"mock": True,
|
||||
}
|
||||
|
||||
payload = {
|
||||
"amount": str(amount_rub),
|
||||
"currency": "RUB",
|
||||
"order_id": order_id,
|
||||
"url_callback": settings.HELEKET_WEBHOOK_URL,
|
||||
}
|
||||
body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
payload = {
|
||||
"shop_id": self.shop_id,
|
||||
"amount": str(amount_rub),
|
||||
"currency": "RUB",
|
||||
"order_id": order_id,
|
||||
"url_return": "",
|
||||
"url_callback": "",
|
||||
}
|
||||
headers = {"merchant": self.api_key, "Content-Type": "application/json"}
|
||||
async with session.post(
|
||||
f"{self.BASE_URL}/payment",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
data=body,
|
||||
headers=self._post_headers(body),
|
||||
timeout=aiohttp.ClientTimeout(total=15),
|
||||
) as resp:
|
||||
data = await resp.json()
|
||||
@@ -70,18 +94,17 @@ class HelketPayment:
|
||||
return "pending"
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
headers = {"merchant": self.api_key}
|
||||
async with session.get(
|
||||
f"{self.BASE_URL}/payment/info/{payment_id}",
|
||||
headers=headers,
|
||||
headers=self._get_headers(),
|
||||
timeout=aiohttp.ClientTimeout(total=15),
|
||||
) as resp:
|
||||
data = await resp.json()
|
||||
if data.get("state") == 0:
|
||||
status = data.get("result", {}).get("status", "")
|
||||
if status == "paid":
|
||||
if status in ("paid", "paid_over"):
|
||||
return "paid"
|
||||
if status in ("cancel", "expired"):
|
||||
if status in ("cancel", "expired", "fail", "wrong_amount"):
|
||||
return "expired"
|
||||
return "pending"
|
||||
return "error"
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
"""
|
||||
Webhook-сервер для приёма уведомлений от Heleket об оплате.
|
||||
|
||||
Heleket шлёт POST на url_callback когда статус счёта меняется.
|
||||
Сервер верифицирует подпись, начисляет баланс и уведомляет пользователя в Telegram.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from config.settings import settings
|
||||
from managers.storage import PaymentStorage, BalanceStorage, RubleBalanceStorage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Доверенный IP Heleket из документации
|
||||
HELEKET_IP = "31.133.220.8"
|
||||
|
||||
|
||||
def _verify_sign(payload: dict, api_key: str) -> bool:
|
||||
"""Верифицирует подпись webhook-запроса от Heleket.
|
||||
|
||||
Алгоритм: md5( base64( json(payload_without_sign) ) + api_key )
|
||||
|
||||
ВАЖНО: PHP json_encode() экранирует '/' как '\/', Python — нет.
|
||||
Heleket вычисляет подпись на PHP, поэтому вручную экранируем слэши.
|
||||
"""
|
||||
received_sign = payload.get("sign", "")
|
||||
data = {k: v for k, v in payload.items() if k != "sign"}
|
||||
data_json = json.dumps(data, separators=(",", ":"), ensure_ascii=False)
|
||||
data_json = data_json.replace("/", "\\/") # имитируем PHP json_encode
|
||||
data_b64 = base64.b64encode(data_json.encode("utf-8")).decode("utf-8")
|
||||
expected = hashlib.md5((data_b64 + api_key).encode("utf-8")).hexdigest()
|
||||
return expected == received_sign
|
||||
|
||||
|
||||
class HelketWebhookServer:
|
||||
"""
|
||||
aiohttp сервер для вебхуков Heleket.
|
||||
Запускается параллельно с Telegram-ботом.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
payment_storage: PaymentStorage,
|
||||
balance_storage: BalanceStorage,
|
||||
rub_storage: RubleBalanceStorage,
|
||||
bot, # aiogram Bot
|
||||
port: int = 8080,
|
||||
):
|
||||
self._payment_storage = payment_storage
|
||||
self._balance_storage = balance_storage
|
||||
self._rub_storage = rub_storage
|
||||
self._bot = bot
|
||||
self._port = port
|
||||
self._app = web.Application()
|
||||
self._runner: Optional[web.AppRunner] = None
|
||||
self._api_key = settings.HELEKET_API_KEY
|
||||
|
||||
self._app.router.add_post("/heleket/webhook", self._handle_webhook)
|
||||
self._app.router.add_get("/health", self._handle_health)
|
||||
|
||||
async def start(self) -> None:
|
||||
self._runner = web.AppRunner(self._app)
|
||||
await self._runner.setup()
|
||||
site = web.TCPSite(self._runner, "0.0.0.0", self._port)
|
||||
await site.start()
|
||||
logger.info(f"Heleket webhook server started on port {self._port}")
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._runner:
|
||||
await self._runner.cleanup()
|
||||
logger.info("Heleket webhook server stopped")
|
||||
|
||||
async def _handle_health(self, request: web.Request) -> web.Response:
|
||||
return web.Response(text="ok")
|
||||
|
||||
async def _handle_webhook(self, request: web.Request) -> web.Response:
|
||||
# Проверяем IP (опционально — может быть за прокси/CDN)
|
||||
client_ip = request.headers.get("X-Forwarded-For", request.remote)
|
||||
if settings.HELEKET_WHITELIST_IP and client_ip and HELEKET_IP not in client_ip:
|
||||
logger.warning(f"Webhook from unknown IP: {client_ip}")
|
||||
return web.Response(status=403, text="Forbidden")
|
||||
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception:
|
||||
logger.warning("Webhook: invalid JSON body")
|
||||
return web.Response(status=400, text="Bad Request")
|
||||
|
||||
logger.debug(f"Webhook payload: {payload}")
|
||||
|
||||
# Верифицируем подпись если есть API ключ
|
||||
if self._api_key and not _verify_sign(payload, self._api_key):
|
||||
logger.warning(f"Webhook: invalid signature. uuid={payload.get('uuid')}")
|
||||
return web.Response(status=400, text="Invalid signature")
|
||||
|
||||
status = payload.get("status", "")
|
||||
payment_uuid = payload.get("uuid", "")
|
||||
|
||||
# paid_over — клиент заплатил больше суммы счёта, тоже засчитываем
|
||||
if status not in ("paid", "paid_over"):
|
||||
logger.info(f"Webhook: uuid={payment_uuid} status={status} — ignored")
|
||||
return web.Response(text="ok")
|
||||
|
||||
# Ищем платёж в хранилище
|
||||
payment = await self._payment_storage.get(payment_uuid)
|
||||
if not payment:
|
||||
logger.warning(f"Webhook: payment {payment_uuid} not found in storage")
|
||||
return web.Response(text="ok") # уже обработан или неизвестный
|
||||
|
||||
user_id = payment.get("user_id")
|
||||
chat_id = payment.get("chat_id") or user_id
|
||||
clicks = payment.get("clicks")
|
||||
rub = payment.get("rub", 0)
|
||||
|
||||
try:
|
||||
if clicks:
|
||||
# Прямая покупка переходов
|
||||
await self._balance_storage.add(user_id, clicks)
|
||||
added_clicks = clicks
|
||||
msg = (
|
||||
f"✅ Оплата получена!\n\n"
|
||||
f"💳 Сумма: {rub} ₽\n"
|
||||
f"🖱 Начислено: {clicks} переходов\n"
|
||||
f"📊 Проверьте баланс в личном кабинете"
|
||||
)
|
||||
else:
|
||||
# Пополнение рублёвого баланса → конвертируем в переходы
|
||||
added_clicks = int(rub // settings.CLICK_PRICE_RUB)
|
||||
await self._balance_storage.add(user_id, added_clicks)
|
||||
await self._rub_storage.add(user_id, rub)
|
||||
msg = (
|
||||
f"✅ Пополнение получено!\n\n"
|
||||
f"💳 Сумма: {rub} ₽\n"
|
||||
f"🖱 Начислено: {added_clicks} переходов "
|
||||
f"({rub} ÷ {settings.CLICK_PRICE_RUB} ₽/переход)\n"
|
||||
f"📊 Проверьте баланс в личном кабинете"
|
||||
)
|
||||
|
||||
await self._payment_storage.delete(payment_uuid)
|
||||
logger.info(
|
||||
f"Webhook: payment {payment_uuid} confirmed — "
|
||||
f"user={user_id} +{added_clicks} clicks"
|
||||
)
|
||||
|
||||
# Уведомляем пользователя
|
||||
if chat_id:
|
||||
try:
|
||||
await self._bot.send_message(chat_id, msg)
|
||||
except Exception as e:
|
||||
logger.warning(f"Webhook: failed to notify user {user_id}: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Webhook: error processing payment {payment_uuid}: {e}", exc_info=True)
|
||||
return web.Response(status=500, text="Internal error")
|
||||
|
||||
return web.Response(text="ok")
|
||||
Reference in New Issue
Block a user