fix payment

This commit is contained in:
Yuriy Yuriev
2026-05-22 19:15:44 +07:00
parent b276794c0a
commit bad7966fc4
4 changed files with 126 additions and 42 deletions
+27 -1
View File
@@ -29,7 +29,7 @@ from services.browser_pool import BrowserPool
from services.visit_service import VisitScheduler
from utils.helpers import parse_range, format_range, extract_domain
from utils.telegram import send_message_safe, send_visit_result
from managers.storage import TaskStorage, ChatStorage, BalanceStorage, RubleBalanceStorage, PaymentStorage
from managers.storage import TaskStorage, ChatStorage, BalanceStorage, RubleBalanceStorage, PaymentStorage, PaymentHistoryStorage
from services.payment_service import HelketPayment
from services.twitch_api import get_viewer_count
from auth.manager import AuthManager
@@ -64,6 +64,7 @@ class BotInterface:
self.balance_storage = BalanceStorage() # переходы
self.rub_storage = RubleBalanceStorage() # рубли
self.payment_storage = PaymentStorage()
self.payment_history = PaymentHistoryStorage()
self.heleket = HelketPayment()
self._user_input_state: Dict[int, dict] = {}
self._payment_state: Dict[int, str] = {} # user_id -> "topup" | "buy_clicks"
@@ -542,6 +543,28 @@ class BotInterface:
)
await callback.answer()
@dp.callback_query(F.data == "payment_history")
async def cb_payment_history(callback: CallbackQuery):
uid = callback.from_user.id
history = await interface.payment_history.get_user_history(uid)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 В кабинет", callback_data="my_balance"))
if not history:
text = "🧾 История платежей\n\nПлатежей пока нет."
else:
lines = ["🧾 История платежей\n"]
for r in history[:10]:
dt = r.get("at", "")[:16].replace("T", " ")
if r.get("type") == "clicks":
lines.append(f"🖱 {r['clicks']} переходов — {r['rub']} ₽ ({dt})")
else:
lines.append(f"💳 Пополнение {r['rub']} ₽ ({dt})")
text = "\n".join(lines)
await callback.message.edit_text(text, reply_markup=builder.as_markup())
await callback.answer()
# ── Пополнение рублей (крипта → рубли) ──────────────────────────────
_TOPUP_PRESETS = [100, 300, 500, 1000, 3000]
@@ -613,6 +636,7 @@ class BotInterface:
payment_data = {
"user_id": uid,
"chat_id": callback.message.chat.id,
"msg_id": callback.message.message_id,
"payment_id": invoice["payment_id"],
"rub": amount,
"address": invoice.get("address"),
@@ -925,6 +949,7 @@ class BotInterface:
payment_data = {
"user_id": uid,
"chat_id": callback.message.chat.id,
"msg_id": callback.message.message_id,
"payment_id": invoice["payment_id"],
"clicks": clicks,
"rub": rub,
@@ -2483,6 +2508,7 @@ class BotInterface:
InlineKeyboardButton(text="💳 Пополнить криптой", callback_data="topup_main"),
InlineKeyboardButton(text="🛒 Купить переходы", callback_data="visit_shop"),
)
builder.row(InlineKeyboardButton(text="🧾 История платежей", callback_data="payment_history"))
builder.row(InlineKeyboardButton(text="🔑 Режим администратора", callback_data="enter_admin_mode"))
await self._edit_or_send(message, text, builder.as_markup(), edit, user_id=user_id)
+3 -1
View File
@@ -16,7 +16,7 @@ from services.browser_service import BrowserService
from services.browser_pool import BrowserPool
from services.webhook_server import HelketWebhookServer
from handlers.commands import BotInterface
from managers.storage import TaskStorage, ChatStorage
from managers.storage import TaskStorage, ChatStorage, PaymentHistoryStorage
logger = setup_logger(__name__)
@@ -50,6 +50,7 @@ class BotApplication:
self.browser_pool = BrowserPool(settings.BROWSER_POOL_SIZE, self.browser_service)
self.storage = TaskStorage()
self.chat_storage = ChatStorage()
self.payment_history = PaymentHistoryStorage()
self.webhook_server: HelketWebhookServer = None # создаём после инициализации bot
self.interface = BotInterface(
@@ -97,6 +98,7 @@ class BotApplication:
payment_storage=self.interface.payment_storage,
balance_storage=self.interface.balance_storage,
rub_storage=self.interface.rub_storage,
history_storage=self.payment_history,
bot=self.bot,
port=settings.HELEKET_WEBHOOK_PORT,
)
+38
View File
@@ -341,3 +341,41 @@ class PaymentStorage:
if data.get("user_id") == user_id:
return {**data, "payment_id": pid}
return None
class PaymentHistoryStorage:
"""История завершённых платежей (последние N на пользователя)."""
MAX_PER_USER = 20
def __init__(self, file_path: str = "data/payment_history.json"):
self.file_path = Path(file_path)
self.file_path.parent.mkdir(parents=True, exist_ok=True)
self._lock = asyncio.Lock()
def _load_sync(self) -> dict:
if not self.file_path.exists():
return {}
try:
with open(self.file_path, "r", encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
return {}
def _save_sync(self, data: dict) -> None:
with open(self.file_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
async def add(self, user_id: int, record: dict) -> None:
async with self._lock:
data = await asyncio.to_thread(self._load_sync)
key = str(user_id)
history = data.get(key, [])
history.append(record)
data[key] = history[-self.MAX_PER_USER:]
await asyncio.to_thread(self._save_sync, data)
async def get_user_history(self, user_id: int) -> list:
async with self._lock:
data = await asyncio.to_thread(self._load_sync)
return list(reversed(data.get(str(user_id), [])))
+55 -37
View File
@@ -10,16 +10,16 @@ import base64
import hashlib
import json
import logging
from datetime import datetime
from typing import Optional
from aiohttp import web
from config.settings import settings
from managers.storage import PaymentStorage, BalanceStorage, RubleBalanceStorage
from managers.storage import PaymentStorage, BalanceStorage, RubleBalanceStorage, PaymentHistoryStorage
logger = logging.getLogger(__name__)
# Доверенный IP Heleket из документации
HELEKET_IP = "31.133.220.8"
@@ -27,36 +27,32 @@ 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, поэтому вручную экранируем слэши.
PHP json_encode() экранирует '/' как '\/', Python — нет, делаем вручную.
"""
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_json = data_json.replace("/", "\\/")
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
history_storage: PaymentHistoryStorage,
bot,
port: int = 8080,
):
self._payment_storage = payment_storage
self._balance_storage = balance_storage
self._rub_storage = rub_storage
self._history_storage = history_storage
self._bot = bot
self._port = port
self._app = web.Application()
@@ -67,7 +63,7 @@ class HelketWebhookServer:
self._app.router.add_get("/health", self._handle_health)
async def start(self) -> None:
self._runner = web.AppRunner(self._app)
self._runner = web.AppRunner(self._app, access_log=None)
await self._runner.setup()
site = web.TCPSite(self._runner, "0.0.0.0", self._port)
await site.start()
@@ -82,7 +78,6 @@ class HelketWebhookServer:
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}")
@@ -94,9 +89,8 @@ class HelketWebhookServer:
logger.warning("Webhook: invalid JSON body")
return web.Response(status=400, text="Bad Request")
logger.debug(f"Webhook payload: {payload}")
logger.info(f"Webhook received: uuid={payload.get('uuid')} status={payload.get('status')}")
# Верифицируем подпись если есть 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")
@@ -104,16 +98,14 @@ class HelketWebhookServer:
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") # уже обработан или неизвестный
logger.warning(f"Webhook: payment {payment_uuid} not found (already processed?)")
return web.Response(text="ok")
user_id = payment.get("user_id")
chat_id = payment.get("chat_id") or user_id
@@ -122,43 +114,69 @@ class HelketWebhookServer:
try:
if clicks:
# Прямая покупка переходов
# Прямая покупка переходов — начисляем переходы
await self._balance_storage.add_balance(user_id, clicks)
added_clicks = clicks
msg = (
f"✅ Оплата получена!\n\n"
f"💳 Сумма: {rub}\n"
f"🖱 Начислено: {clicks} переходов\n"
f"📊 Проверьте баланс в личном кабинете"
f"🖱 Начислено: {clicks} переходов"
)
history_record = {
"type": "clicks",
"rub": rub,
"clicks": clicks,
"uuid": payment_uuid,
"at": datetime.now().isoformat(timespec="seconds"),
}
logger.info(f"Webhook: user={user_id} +{clicks} clicks")
else:
# Пополнение рублёвого баланса → конвертируем в переходы
added_clicks = int(rub // settings.CLICK_PRICE_RUB)
await self._balance_storage.add_balance(user_id, added_clicks)
# Пополнение — начисляем только рубли, переходы пользователь купит сам
await self._rub_storage.add_balance(user_id, rub)
new_rub = await self._rub_storage.get_balance(user_id)
msg = (
f"Пополнение получено!\n\n"
f"Баланс пополнен!\n\n"
f"💳 Сумма: {rub}\n"
f"🖱 Начислено: {added_clicks} переходов "
f"({rub} ÷ {settings.CLICK_PRICE_RUB} ₽/переход)\n"
f"📊 Проверьте баланс в личном кабинете"
f"🪙 Рублей на балансе: {new_rub} \n\n"
f"Перейдите в кабинет чтобы купить переходы."
)
history_record = {
"type": "topup",
"rub": rub,
"uuid": payment_uuid,
"at": datetime.now().isoformat(timespec="seconds"),
}
logger.info(f"Webhook: user={user_id} +{rub} RUB")
await self._payment_storage.delete(payment_uuid)
logger.info(
f"Webhook: payment {payment_uuid} confirmed — "
f"user={user_id} +{added_clicks} clicks"
)
await self._history_storage.add(user_id, history_record)
# Уведомляем пользователя
if chat_id:
from aiogram.types import InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🏠 Личный кабинет", callback_data="my_balance"))
kb = builder.as_markup()
msg_id = payment.get("msg_id")
edited = False
if msg_id:
try:
await self._bot.send_message(chat_id, msg)
await self._bot.edit_message_text(
msg, chat_id=chat_id, message_id=msg_id,
reply_markup=kb
)
edited = True
except Exception:
pass
if not edited:
try:
await self._bot.send_message(chat_id, msg, reply_markup=kb)
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)
logger.error(f"Webhook: error processing {payment_uuid}: {e}", exc_info=True)
return web.Response(status=500, text="Internal error")
return web.Response(text="ok")