fix payment
This commit is contained in:
+27
-1
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user