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 services.visit_service import VisitScheduler
from utils.helpers import parse_range, format_range, extract_domain from utils.helpers import parse_range, format_range, extract_domain
from utils.telegram import send_message_safe, send_visit_result 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.payment_service import HelketPayment
from services.twitch_api import get_viewer_count from services.twitch_api import get_viewer_count
from auth.manager import AuthManager from auth.manager import AuthManager
@@ -64,6 +64,7 @@ class BotInterface:
self.balance_storage = BalanceStorage() # переходы self.balance_storage = BalanceStorage() # переходы
self.rub_storage = RubleBalanceStorage() # рубли self.rub_storage = RubleBalanceStorage() # рубли
self.payment_storage = PaymentStorage() self.payment_storage = PaymentStorage()
self.payment_history = PaymentHistoryStorage()
self.heleket = HelketPayment() self.heleket = HelketPayment()
self._user_input_state: Dict[int, dict] = {} self._user_input_state: Dict[int, dict] = {}
self._payment_state: Dict[int, str] = {} # user_id -> "topup" | "buy_clicks" self._payment_state: Dict[int, str] = {} # user_id -> "topup" | "buy_clicks"
@@ -542,6 +543,28 @@ class BotInterface:
) )
await callback.answer() 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] _TOPUP_PRESETS = [100, 300, 500, 1000, 3000]
@@ -613,6 +636,7 @@ class BotInterface:
payment_data = { payment_data = {
"user_id": uid, "user_id": uid,
"chat_id": callback.message.chat.id, "chat_id": callback.message.chat.id,
"msg_id": callback.message.message_id,
"payment_id": invoice["payment_id"], "payment_id": invoice["payment_id"],
"rub": amount, "rub": amount,
"address": invoice.get("address"), "address": invoice.get("address"),
@@ -925,6 +949,7 @@ class BotInterface:
payment_data = { payment_data = {
"user_id": uid, "user_id": uid,
"chat_id": callback.message.chat.id, "chat_id": callback.message.chat.id,
"msg_id": callback.message.message_id,
"payment_id": invoice["payment_id"], "payment_id": invoice["payment_id"],
"clicks": clicks, "clicks": clicks,
"rub": rub, "rub": rub,
@@ -2483,6 +2508,7 @@ class BotInterface:
InlineKeyboardButton(text="💳 Пополнить криптой", callback_data="topup_main"), InlineKeyboardButton(text="💳 Пополнить криптой", callback_data="topup_main"),
InlineKeyboardButton(text="🛒 Купить переходы", callback_data="visit_shop"), InlineKeyboardButton(text="🛒 Купить переходы", callback_data="visit_shop"),
) )
builder.row(InlineKeyboardButton(text="🧾 История платежей", callback_data="payment_history"))
builder.row(InlineKeyboardButton(text="🔑 Режим администратора", callback_data="enter_admin_mode")) builder.row(InlineKeyboardButton(text="🔑 Режим администратора", callback_data="enter_admin_mode"))
await self._edit_or_send(message, text, builder.as_markup(), edit, user_id=user_id) 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.browser_pool import BrowserPool
from services.webhook_server import HelketWebhookServer from services.webhook_server import HelketWebhookServer
from handlers.commands import BotInterface from handlers.commands import BotInterface
from managers.storage import TaskStorage, ChatStorage from managers.storage import TaskStorage, ChatStorage, PaymentHistoryStorage
logger = setup_logger(__name__) logger = setup_logger(__name__)
@@ -50,6 +50,7 @@ class BotApplication:
self.browser_pool = BrowserPool(settings.BROWSER_POOL_SIZE, self.browser_service) self.browser_pool = BrowserPool(settings.BROWSER_POOL_SIZE, self.browser_service)
self.storage = TaskStorage() self.storage = TaskStorage()
self.chat_storage = ChatStorage() self.chat_storage = ChatStorage()
self.payment_history = PaymentHistoryStorage()
self.webhook_server: HelketWebhookServer = None # создаём после инициализации bot self.webhook_server: HelketWebhookServer = None # создаём после инициализации bot
self.interface = BotInterface( self.interface = BotInterface(
@@ -97,6 +98,7 @@ class BotApplication:
payment_storage=self.interface.payment_storage, payment_storage=self.interface.payment_storage,
balance_storage=self.interface.balance_storage, balance_storage=self.interface.balance_storage,
rub_storage=self.interface.rub_storage, rub_storage=self.interface.rub_storage,
history_storage=self.payment_history,
bot=self.bot, bot=self.bot,
port=settings.HELEKET_WEBHOOK_PORT, port=settings.HELEKET_WEBHOOK_PORT,
) )
+38
View File
@@ -341,3 +341,41 @@ class PaymentStorage:
if data.get("user_id") == user_id: if data.get("user_id") == user_id:
return {**data, "payment_id": pid} return {**data, "payment_id": pid}
return None 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 hashlib
import json import json
import logging import logging
from datetime import datetime
from typing import Optional from typing import Optional
from aiohttp import web from aiohttp import web
from config.settings import settings from config.settings import settings
from managers.storage import PaymentStorage, BalanceStorage, RubleBalanceStorage from managers.storage import PaymentStorage, BalanceStorage, RubleBalanceStorage, PaymentHistoryStorage
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Доверенный IP Heleket из документации
HELEKET_IP = "31.133.220.8" HELEKET_IP = "31.133.220.8"
@@ -27,36 +27,32 @@ def _verify_sign(payload: dict, api_key: str) -> bool:
"""Верифицирует подпись webhook-запроса от Heleket. """Верифицирует подпись webhook-запроса от Heleket.
Алгоритм: md5( base64( json(payload_without_sign) ) + api_key ) Алгоритм: md5( base64( json(payload_without_sign) ) + api_key )
PHP json_encode() экранирует '/' как '\/', Python — нет, делаем вручную.
ВАЖНО: PHP json_encode() экранирует '/' как '\/', Python — нет.
Heleket вычисляет подпись на PHP, поэтому вручную экранируем слэши.
""" """
received_sign = payload.get("sign", "") received_sign = payload.get("sign", "")
data = {k: v for k, v in payload.items() if k != "sign"} data = {k: v for k, v in payload.items() if k != "sign"}
data_json = json.dumps(data, separators=(",", ":"), ensure_ascii=False) 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") data_b64 = base64.b64encode(data_json.encode("utf-8")).decode("utf-8")
expected = hashlib.md5((data_b64 + api_key).encode("utf-8")).hexdigest() expected = hashlib.md5((data_b64 + api_key).encode("utf-8")).hexdigest()
return expected == received_sign return expected == received_sign
class HelketWebhookServer: class HelketWebhookServer:
"""
aiohttp сервер для вебхуков Heleket.
Запускается параллельно с Telegram-ботом.
"""
def __init__( def __init__(
self, self,
payment_storage: PaymentStorage, payment_storage: PaymentStorage,
balance_storage: BalanceStorage, balance_storage: BalanceStorage,
rub_storage: RubleBalanceStorage, rub_storage: RubleBalanceStorage,
bot, # aiogram Bot history_storage: PaymentHistoryStorage,
bot,
port: int = 8080, port: int = 8080,
): ):
self._payment_storage = payment_storage self._payment_storage = payment_storage
self._balance_storage = balance_storage self._balance_storage = balance_storage
self._rub_storage = rub_storage self._rub_storage = rub_storage
self._history_storage = history_storage
self._bot = bot self._bot = bot
self._port = port self._port = port
self._app = web.Application() self._app = web.Application()
@@ -67,7 +63,7 @@ class HelketWebhookServer:
self._app.router.add_get("/health", self._handle_health) self._app.router.add_get("/health", self._handle_health)
async def start(self) -> None: async def start(self) -> None:
self._runner = web.AppRunner(self._app) self._runner = web.AppRunner(self._app, access_log=None)
await self._runner.setup() await self._runner.setup()
site = web.TCPSite(self._runner, "0.0.0.0", self._port) site = web.TCPSite(self._runner, "0.0.0.0", self._port)
await site.start() await site.start()
@@ -82,7 +78,6 @@ class HelketWebhookServer:
return web.Response(text="ok") return web.Response(text="ok")
async def _handle_webhook(self, request: web.Request) -> web.Response: async def _handle_webhook(self, request: web.Request) -> web.Response:
# Проверяем IP (опционально — может быть за прокси/CDN)
client_ip = request.headers.get("X-Forwarded-For", request.remote) 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: if settings.HELEKET_WHITELIST_IP and client_ip and HELEKET_IP not in client_ip:
logger.warning(f"Webhook from unknown IP: {client_ip}") logger.warning(f"Webhook from unknown IP: {client_ip}")
@@ -94,9 +89,8 @@ class HelketWebhookServer:
logger.warning("Webhook: invalid JSON body") logger.warning("Webhook: invalid JSON body")
return web.Response(status=400, text="Bad Request") 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): if self._api_key and not _verify_sign(payload, self._api_key):
logger.warning(f"Webhook: invalid signature. uuid={payload.get('uuid')}") logger.warning(f"Webhook: invalid signature. uuid={payload.get('uuid')}")
return web.Response(status=400, text="Invalid signature") return web.Response(status=400, text="Invalid signature")
@@ -104,16 +98,14 @@ class HelketWebhookServer:
status = payload.get("status", "") status = payload.get("status", "")
payment_uuid = payload.get("uuid", "") payment_uuid = payload.get("uuid", "")
# paid_over — клиент заплатил больше суммы счёта, тоже засчитываем
if status not in ("paid", "paid_over"): if status not in ("paid", "paid_over"):
logger.info(f"Webhook: uuid={payment_uuid} status={status} — ignored") logger.info(f"Webhook: uuid={payment_uuid} status={status} — ignored")
return web.Response(text="ok") return web.Response(text="ok")
# Ищем платёж в хранилище
payment = await self._payment_storage.get(payment_uuid) payment = await self._payment_storage.get(payment_uuid)
if not payment: if not payment:
logger.warning(f"Webhook: payment {payment_uuid} not found in storage") logger.warning(f"Webhook: payment {payment_uuid} not found (already processed?)")
return web.Response(text="ok") # уже обработан или неизвестный return web.Response(text="ok")
user_id = payment.get("user_id") user_id = payment.get("user_id")
chat_id = payment.get("chat_id") or user_id chat_id = payment.get("chat_id") or user_id
@@ -122,43 +114,69 @@ class HelketWebhookServer:
try: try:
if clicks: if clicks:
# Прямая покупка переходов # Прямая покупка переходов — начисляем переходы
await self._balance_storage.add_balance(user_id, clicks) await self._balance_storage.add_balance(user_id, clicks)
added_clicks = clicks
msg = ( msg = (
f"✅ Оплата получена!\n\n" f"✅ Оплата получена!\n\n"
f"💳 Сумма: {rub}\n" f"💳 Сумма: {rub}\n"
f"🖱 Начислено: {clicks} переходов\n" f"🖱 Начислено: {clicks} переходов"
f"📊 Проверьте баланс в личном кабинете"
) )
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: 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) await self._rub_storage.add_balance(user_id, rub)
new_rub = await self._rub_storage.get_balance(user_id)
msg = ( msg = (
f"Пополнение получено!\n\n" f"Баланс пополнен!\n\n"
f"💳 Сумма: {rub}\n" f"💳 Сумма: {rub}\n"
f"🖱 Начислено: {added_clicks} переходов " f"🪙 Рублей на балансе: {new_rub} \n\n"
f"({rub} ÷ {settings.CLICK_PRICE_RUB} ₽/переход)\n" f"Перейдите в кабинет чтобы купить переходы."
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) await self._payment_storage.delete(payment_uuid)
logger.info( await self._history_storage.add(user_id, history_record)
f"Webhook: payment {payment_uuid} confirmed — "
f"user={user_id} +{added_clicks} clicks"
)
# Уведомляем пользователя
if chat_id: 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: 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: except Exception as e:
logger.warning(f"Webhook: failed to notify user {user_id}: {e}") logger.warning(f"Webhook: failed to notify user {user_id}: {e}")
except Exception as 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(status=500, text="Internal error")
return web.Response(text="ok") return web.Response(text="ok")