Files
remnawave-bedolaga-telegram…/app/services/freekassa_service.py
T
2026-01-11 05:44:43 +03:00

443 lines
17 KiB
Python

"""Сервис для работы с API Freekassa."""
import hashlib
import hmac
import time
import logging
import asyncio
import json
import urllib.request
from typing import Optional, Dict, Any, Set
import aiohttp
from app.config import settings
logger = logging.getLogger(__name__)
# Кэш для публичного IP
_cached_public_ip: Optional[str] = None
_ip_fetch_lock = asyncio.Lock()
# IP-адреса Freekassa для проверки webhook
FREEKASSA_IPS: Set[str] = {
"168.119.157.136",
"168.119.60.227",
"178.154.197.79",
"51.250.54.238",
}
API_BASE_URL = "https://api.fk.life/v1"
# Сервисы для определения публичного IP (в порядке приоритета)
IP_SERVICES = [
"https://api.ipify.org",
"https://ifconfig.me/ip",
"https://icanhazip.com",
"https://ipinfo.io/ip",
]
async def get_public_ip() -> str:
"""
Получает публичный IP сервера.
1. Сначала проверяет переменную окружения SERVER_PUBLIC_IP
2. Если нет - запрашивает через внешние сервисы и кэширует
"""
global _cached_public_ip
# Проверяем переменную окружения
env_ip = getattr(settings, 'SERVER_PUBLIC_IP', None)
if env_ip:
return env_ip
# Возвращаем кэшированный IP если есть
if _cached_public_ip:
return _cached_public_ip
async with _ip_fetch_lock:
# Повторная проверка после получения блокировки
if _cached_public_ip:
return _cached_public_ip
# Пробуем получить IP от внешних сервисов
async with aiohttp.ClientSession() as session:
for service_url in IP_SERVICES:
try:
async with session.get(
service_url,
timeout=aiohttp.ClientTimeout(total=5)
) as response:
if response.status == 200:
ip = (await response.text()).strip()
# Простая валидация IPv4
if ip and len(ip.split('.')) == 4:
_cached_public_ip = ip
logger.info(f"Определён публичный IP сервера: {ip}")
return ip
except Exception as e:
logger.debug(f"Не удалось получить IP от {service_url}: {e}")
continue
# Fallback на известный рабочий IP если ничего не получилось
fallback_ip = "185.92.183.173"
logger.warning(f"Не удалось определить публичный IP, используем fallback: {fallback_ip}")
_cached_public_ip = fallback_ip
return fallback_ip
class FreekassaService:
"""Сервис для работы с API Freekassa."""
def __init__(self):
self._shop_id: Optional[int] = None
self._api_key: Optional[str] = None
self._secret1: Optional[str] = None
self._secret2: Optional[str] = None
@property
def shop_id(self) -> int:
if self._shop_id is None:
self._shop_id = settings.FREEKASSA_SHOP_ID
return self._shop_id or 0
@property
def api_key(self) -> str:
if self._api_key is None:
self._api_key = settings.FREEKASSA_API_KEY
return self._api_key or ""
@property
def secret1(self) -> str:
if self._secret1 is None:
self._secret1 = settings.FREEKASSA_SECRET_WORD_1
return self._secret1 or ""
@property
def secret2(self) -> str:
if self._secret2 is None:
self._secret2 = settings.FREEKASSA_SECRET_WORD_2
return self._secret2 or ""
def _generate_api_signature_hmac(self, params: Dict[str, Any]) -> str:
"""
Генерирует подпись для API запроса (HMAC-SHA256).
Используется для API методов (создание заказа и т.д.)
"""
# Исключаем signature из параметров и сортируем по ключу
sign_data = {k: v for k, v in params.items() if k != "signature"}
sorted_items = sorted(sign_data.items())
# Формируем строку: значения через |
msg = "|".join(str(v) for _, v in sorted_items)
# HMAC-SHA256
return hmac.new(
self.api_key.encode("utf-8"),
msg.encode("utf-8"),
hashlib.sha256
).hexdigest()
def _generate_api_signature(self, params: Dict[str, Any]) -> str:
"""
Генерирует подпись для API запроса.
Для новых API методов используется HMAC-SHA256.
"""
return self._generate_api_signature_hmac(params)
def generate_form_signature(
self, amount: float, currency: str, order_id: str
) -> str:
"""
Генерирует подпись для платежной формы.
Формат: MD5(shop_id:amount:secret1:currency:order_id)
"""
# Приводим amount к int, если это целое число
final_amount = int(amount) if float(amount).is_integer() else amount
sign_string = f"{self.shop_id}:{final_amount}:{self.secret1}:{currency}:{order_id}"
return hashlib.md5(sign_string.encode()).hexdigest()
def verify_webhook_signature(
self, shop_id: int, amount: float, order_id: str, sign: str
) -> bool:
"""
Проверяет подпись webhook уведомления.
Формат: MD5(shop_id:amount:secret2:order_id)
"""
# Приводим amount к int, если это целое число
final_amount = int(amount) if float(amount).is_integer() else amount
expected_sign = hashlib.md5(
f"{shop_id}:{final_amount}:{self.secret2}:{order_id}".encode()
).hexdigest()
return sign.lower() == expected_sign.lower()
def verify_webhook_ip(self, ip: str) -> bool:
"""Проверяет, что IP входит в разрешенный список Freekassa."""
return ip in FREEKASSA_IPS
def build_payment_url(
self,
order_id: str,
amount: float,
currency: str = "RUB",
email: Optional[str] = None,
phone: Optional[str] = None,
payment_system_id: Optional[int] = None,
lang: str = "ru",
ip: Optional[str] = None,
) -> str:
"""
Формирует URL для перенаправления на оплату (форма выбора).
Используется когда FREEKASSA_USE_API = False.
"""
# Приводим amount к int, если это целое число
final_amount = int(amount) if float(amount).is_integer() else amount
# Используем payment_system_id из настроек, если не передан явно
ps_id = payment_system_id or settings.FREEKASSA_PAYMENT_SYSTEM_ID
# Специальная обработка для метода оплаты 44 (NSPK), чтобы работало как в старой версии
if ps_id == 44:
try:
# Определяем IP (важно для API запроса) - здесь синхронно, поэтому лучше иметь передачу IP
# Если IP не передан, используем fallback
target_ip = ip or "185.92.183.173"
target_email = email or "test@example.com"
params = {
"shopId": self.shop_id,
"nonce": int(time.time_ns()),
"paymentId": str(order_id),
"i": 44,
"email": target_email,
"ip": target_ip,
"amount": final_amount,
"currency": "RUB"
}
# Генерация подписи
params["signature"] = self._generate_api_signature(params)
logger.info(f"Freekassa synchronous build_payment_url for 44: {params}")
data_json = json.dumps(params).encode('utf-8')
req = urllib.request.Request(
f"{API_BASE_URL}/orders/create",
data=data_json,
headers={"Content-Type": "application/json"}
)
with urllib.request.urlopen(req, timeout=30) as response:
resp_body = response.read().decode('utf-8')
data = json.loads(resp_body)
if data.get("type") == "error":
logger.error(f"Freekassa build_payment_url error: {data}")
# Fallback to standard flow if error? Or raise?
# User wants it to work. Raise to see error is safer.
# raise Exception(f"Freekassa API Error: {data.get('message')}")
# Но чтобы не ломать полностью, можно попробовать вернуть обычную ссылку,
# если API не сработал? Нет, вернем ошибку или ссылку из data.
if data.get("location"):
return data.get("location")
except Exception as e:
logger.error(f"Failed to create order 44 via sync API: {e}")
# Если не получилось, попробуем сгенерировать обычную ссылку как fallback
pass
signature = self.generate_form_signature(final_amount, currency, order_id)
params = {
"m": self.shop_id,
"oa": final_amount,
"currency": currency,
"o": order_id,
"s": signature,
"lang": lang,
}
if email:
params["em"] = email
if phone:
params["phone"] = phone
if ps_id:
params["i"] = ps_id
query = "&".join(f"{k}={v}" for k, v in params.items())
return f"https://pay.freekassa.ru/?{query}"
async def create_order(
self,
order_id: str,
amount: float,
currency: str = "RUB",
email: Optional[str] = None,
ip: Optional[str] = None,
payment_system_id: Optional[int] = None,
success_url: Optional[str] = None,
failure_url: Optional[str] = None,
notification_url: Optional[str] = None,
) -> Dict[str, Any]:
"""
Создает заказ через API Freekassa.
POST /orders/create
Используется для NSPK СБП (payment_system_id=44) и других методов.
Возвращает словарь с 'location' (ссылка на оплату).
"""
# Приводим amount к int, если это целое число
final_amount = int(amount) if float(amount).is_integer() else amount
# Используем payment_system_id из настроек, если не передан явно
ps_id = payment_system_id or settings.FREEKASSA_PAYMENT_SYSTEM_ID or 1
target_email = email or "test@example.com"
# Определяем публичный IP сервера
server_ip = ip or await get_public_ip()
params = {
"shopId": self.shop_id,
"nonce": int(time.time_ns()), # Наносекунды для уникальности
"paymentId": str(order_id),
"i": ps_id,
"email": target_email,
"ip": server_ip,
"amount": final_amount,
"currency": currency,
}
# Генерируем подпись HMAC-SHA256
params["signature"] = self._generate_api_signature(params)
logger.info(f"Freekassa API create_order params: {params}")
try:
async with aiohttp.ClientSession() as session:
async with session.post(
f"{API_BASE_URL}/orders/create",
json=params,
headers={"Content-Type": "application/json"},
timeout=aiohttp.ClientTimeout(total=30),
) as response:
text = await response.text()
logger.info(f"Freekassa API response: {text}")
data = await response.json()
# Проверяем на ошибку - API может вернуть error или type=error
error_msg = data.get("error") or data.get("message")
if response.status != 200 or data.get("type") == "error" or error_msg:
logger.error(f"Freekassa create_order error: {data}")
raise Exception(
f"Freekassa API error: {error_msg or 'Unknown error'}"
)
return data
except aiohttp.ClientError as e:
logger.exception(f"Freekassa API connection error: {e}")
raise
async def create_order_and_get_url(
self,
order_id: str,
amount: float,
currency: str = "RUB",
email: Optional[str] = None,
ip: Optional[str] = None,
payment_system_id: Optional[int] = None,
) -> str:
"""
Создает заказ через API и возвращает URL для оплаты.
Удобный метод для получения только ссылки.
"""
result = await self.create_order(
order_id=order_id,
amount=amount,
currency=currency,
email=email,
ip=ip,
payment_system_id=payment_system_id,
)
location = result.get("location")
if not location:
raise Exception("Freekassa API did not return payment URL (location)")
return location
async def get_order_status(self, order_id: str) -> Dict[str, Any]:
"""
Получает статус заказа.
POST /orders
"""
params = {
"shopId": self.shop_id,
"nonce": int(time.time_ns()),
"paymentId": str(order_id),
}
params["signature"] = self._generate_api_signature(params)
logger.debug(f"Freekassa get_order_status params: {params}")
try:
async with aiohttp.ClientSession() as session:
async with session.post(
f"{API_BASE_URL}/orders",
json=params,
headers={"Content-Type": "application/json"},
timeout=aiohttp.ClientTimeout(total=30),
) as response:
text = await response.text()
logger.debug(f"Freekassa get_order_status response: {text}")
return await response.json()
except aiohttp.ClientError as e:
logger.exception(f"Freekassa API connection error: {e}")
raise
async def get_balance(self) -> Dict[str, Any]:
"""Получает баланс магазина."""
params = {
"shopId": self.shop_id,
"nonce": int(time.time_ns()),
}
params["signature"] = self._generate_api_signature(params)
try:
async with aiohttp.ClientSession() as session:
async with session.post(
f"{API_BASE_URL}/balance",
json=params,
headers={"Content-Type": "application/json"},
timeout=aiohttp.ClientTimeout(total=30),
) as response:
return await response.json()
except aiohttp.ClientError as e:
logger.exception(f"Freekassa API connection error: {e}")
raise
async def get_payment_systems(self) -> Dict[str, Any]:
"""Получает список доступных платежных систем."""
params = {
"shopId": self.shop_id,
"nonce": int(time.time_ns()),
}
params["signature"] = self._generate_api_signature(params)
try:
async with aiohttp.ClientSession() as session:
async with session.post(
f"{API_BASE_URL}/currencies",
json=params,
headers={"Content-Type": "application/json"},
timeout=aiohttp.ClientTimeout(total=30),
) as response:
return await response.json()
except aiohttp.ClientError as e:
logger.exception(f"Freekassa API connection error: {e}")
raise
# Singleton instance
freekassa_service = FreekassaService()