This commit is contained in:
2026-07-09 16:45:17 +03:00
parent 5c7a9710f3
commit da24ed8ba7
23 changed files with 1542 additions and 358 deletions

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import html
import logging
import unicodedata
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from math import ceil
@@ -30,6 +31,77 @@ REFERRAL_BONUS_APPLIED_STATUS = "APPLIED"
logger = logging.getLogger(__name__)
_CYRILLIC_TRANSLITERATION = str.maketrans(
{
"\u0410": "A",
"\u0411": "B",
"\u0412": "V",
"\u0413": "G",
"\u0414": "D",
"\u0415": "E",
"\u0401": "E",
"\u0416": "Zh",
"\u0417": "Z",
"\u0418": "I",
"\u0419": "Y",
"\u041a": "K",
"\u041b": "L",
"\u041c": "M",
"\u041d": "N",
"\u041e": "O",
"\u041f": "P",
"\u0420": "R",
"\u0421": "S",
"\u0422": "T",
"\u0423": "U",
"\u0424": "F",
"\u0425": "Kh",
"\u0426": "Ts",
"\u0427": "Ch",
"\u0428": "Sh",
"\u0429": "Sch",
"\u042a": "",
"\u042b": "Y",
"\u042c": "",
"\u042d": "E",
"\u042e": "Yu",
"\u042f": "Ya",
"\u0430": "a",
"\u0431": "b",
"\u0432": "v",
"\u0433": "g",
"\u0434": "d",
"\u0435": "e",
"\u0451": "e",
"\u0436": "zh",
"\u0437": "z",
"\u0438": "i",
"\u0439": "y",
"\u043a": "k",
"\u043b": "l",
"\u043c": "m",
"\u043d": "n",
"\u043e": "o",
"\u043f": "p",
"\u0440": "r",
"\u0441": "s",
"\u0442": "t",
"\u0443": "u",
"\u0444": "f",
"\u0445": "kh",
"\u0446": "ts",
"\u0447": "ch",
"\u0448": "sh",
"\u0449": "sch",
"\u044a": "",
"\u044b": "y",
"\u044c": "",
"\u044d": "e",
"\u044e": "yu",
"\u044f": "ya",
}
)
@dataclass(slots=True)
class AppliedDiscount:
@@ -196,14 +268,35 @@ class PaymentService:
)
existing_accesses = await self._sync_service.get_cached_users_for_telegram(telegram_id)
renewal_target_record = self._select_cached_renewal_target(existing_accesses)
renewal_target_uuid: str | None = None
renewal_target_username: str | None = None
if renewal_target_record is not None:
renewal_target_uuid = str(renewal_target_record.rw_uuid)
renewal_target_username = renewal_target_record.username
else:
try:
remote_users = await self._remnawave_client.get_users_by_telegram_id(
telegram_id
)
except RemnawaveApiError:
remote_users = []
remote_user = self._select_referral_bonus_target(remote_users)
if remote_user is not None:
renewal_target_uuid = str(remote_user.uuid)
renewal_target_username = remote_user.username
order_uuid = str(uuid4())
invoice_payload = f"manual:{plan.code}:{order_uuid}"
provision_username = self._build_provision_username(
config=config,
order_uuid=order_uuid,
telegram_id=telegram_id,
username=username,
first_name=first_name,
provision_username = (
renewal_target_username
or self._build_provision_username(
config=config,
order_uuid=order_uuid,
telegram_id=telegram_id,
username=username,
first_name=first_name,
)
)
async with self._session_factory() as session:
@@ -230,6 +323,7 @@ class PaymentService:
status=PENDING_PAYMENT_STATUS,
invoice_payload=invoice_payload,
provision_username=provision_username,
remnawave_user_uuid=renewal_target_uuid,
error_message=plan.discount_code or None,
)
)
@@ -240,7 +334,7 @@ class PaymentService:
provision_username=provision_username,
plan=plan,
transfer_text=config.payment_transfer_text.strip(),
extends_existing_access=bool(existing_accesses),
extends_existing_access=bool(existing_accesses) or renewal_target_uuid is not None,
)
async def get_order_for_user(self, *, order_uuid: str, telegram_id: int) -> StoredPaymentOrder:
@@ -350,10 +444,17 @@ class PaymentService:
raise
if remote_user is None:
remote_users = await self._remnawave_client.get_users_by_telegram_id(
order.telegram_id
)
remote_user = self._select_referral_bonus_target(remote_users)
try:
remote_users = await self._remnawave_client.get_users_by_telegram_id(
order.telegram_id
)
remote_user = self._select_referral_bonus_target(remote_users)
except Exception:
logger.warning(
"Failed to look up user by telegram_id=%s, will try to create",
order.telegram_id,
exc_info=True,
)
if remote_user is None:
remote_user = await self._create_remnawave_user(
@@ -375,6 +476,19 @@ class PaymentService:
except RemnawaveApiError as exc:
await self._restore_review_after_failed_approval(order.order_uuid, exc.message)
raise
except Exception as exc:
error_msg = f"Unexpected error: {exc.__class__.__name__}: {exc}"
logger.error(
"Unexpected error during order approval %s: %s",
order.order_uuid,
error_msg,
exc_info=True,
)
await self._restore_review_after_failed_approval(order.order_uuid, error_msg)
raise RemnawaveApiError(
status_code=0,
message=f"Ошибка подключения к панели. Попробуйте ещё раз. ({exc.__class__.__name__})",
)
await self._save_fulfilled_order(order.order_uuid, remote_user)
@@ -772,7 +886,21 @@ class PaymentService:
if order.status == PENDING_PAYMENT_STATUS:
raise ValueError("Чек ещё не отправлен на проверку.")
if order.status == PROCESSING_PAYMENT_STATUS:
raise ValueError("Заказ уже обрабатывается другим модератором.")
# If the order has been stuck in PROCESSING for more than 60s,
# it means the previous approval attempt crashed (e.g. ConnectError).
# Allow re-claiming it instead of blocking forever.
stale_threshold = utcnow() - timedelta(seconds=60)
if order.updated_at and order.updated_at > stale_threshold:
raise ValueError("Заказ уже обрабатывается другим модератором.")
logger.warning(
"Order %s was stuck in PROCESSING since %s, reclaiming",
order_uuid,
order.updated_at,
)
# Already in PROCESSING — just refresh the timestamp and proceed
order.updated_at = utcnow()
await session.commit()
return order
if order.status == REJECTED_PAYMENT_STATUS:
raise ValueError("Заказ уже отклонён.")
if order.status == FULFILLED_PAYMENT_STATUS:
@@ -806,7 +934,33 @@ class PaymentService:
body["tag"] = config.payment_user_tag_normalized
if config.payment_external_squad_uuid_normalized:
body["externalSquadUuid"] = config.payment_external_squad_uuid_normalized
return await self._remnawave_client.create_user(body)
try:
return await self._remnawave_client.create_user(body)
except RemnawaveApiError as exc:
if exc.status_code != 400:
raise
# Creation failed (likely duplicate telegramId) — fall back to
# finding the existing user and extending their subscription.
logger.warning(
"POST /api/users returned 400 for order=%s telegram_id=%s: %s. "
"Falling back to extend existing user.",
order.order_uuid,
order.telegram_id,
exc.message,
)
remote_users = await self._remnawave_client.get_users_by_telegram_id(
order.telegram_id
)
existing_user = self._select_referral_bonus_target(remote_users)
if existing_user is None:
raise # re-raise original 400 if we truly can't find anyone
return await self._extend_remnawave_user(
order,
existing_user,
plan=plan,
config=config,
)
async def _extend_remnawave_user(
self,
@@ -1068,6 +1222,28 @@ class PaymentService:
return sorted(remote_users, key=_sort_key)[0]
@staticmethod
def _select_cached_renewal_target(cached_users: list[object]) -> object | None:
records = [
record
for access in cached_users
if (record := getattr(access, "record", None)) is not None
and getattr(record, "rw_uuid", None)
and getattr(record, "username", None)
]
if not records:
return None
def _sort_key(record: object) -> tuple[int, datetime, str]:
expire_at = getattr(record, "expire_at", None) or datetime.min
if expire_at.tzinfo is None:
expire_at = expire_at.replace(tzinfo=timezone.utc)
status = str(getattr(record, "status", "")).upper()
username = str(getattr(record, "username", ""))
return (0 if status == "ACTIVE" else 1, expire_at, username)
return sorted(records, key=_sort_key)[0]
async def _upsert_telegram_user(
self,
session: AsyncSession,
@@ -1107,13 +1283,19 @@ class PaymentService:
first_name: str | None,
) -> str:
base_name = username or first_name or "user"
normalized_name = "".join(
ch for ch in base_name if ch.isalnum() or ch in "_-"
).strip("-_")
if not normalized_name:
normalized_name = "user"
normalized_name = self._normalize_provision_name(base_name)
prefix = config.payment_username_prefix_normalized or "Oreol"
unique_suffix = order_uuid.replace("-", "")[:6]
provision_username = f"{prefix}-{telegram_id}-{unique_suffix}-{normalized_name}"
return provision_username[:36]
@staticmethod
def _normalize_provision_name(raw_name: str) -> str:
transliterated = raw_name.translate(_CYRILLIC_TRANSLITERATION)
decomposed = unicodedata.normalize("NFKD", transliterated)
ascii_name = decomposed.encode("ascii", "ignore").decode("ascii")
normalized_name = "".join(
ch for ch in ascii_name if ch.isalnum() or ch in "_-"
).strip("-_")
return normalized_name or "user"