479 lines
16 KiB
Python
479 lines
16 KiB
Python
from __future__ import annotations
|
||
|
||
import uuid
|
||
|
||
from dataclasses import dataclass, field
|
||
from datetime import datetime
|
||
from math import ceil
|
||
from typing import TYPE_CHECKING, Any
|
||
|
||
from core.settings.tariffs_config import normalize_tariff_config
|
||
from database import (
|
||
get_balance,
|
||
get_key_details,
|
||
get_tariff_by_id,
|
||
reset_key_current_limits_to_selected,
|
||
save_key_config_with_mode,
|
||
update_balance,
|
||
update_key_expiry,
|
||
update_trial,
|
||
)
|
||
from database.access.resolution import resolve_user_optional
|
||
from database.coupons import mark_coupon_used
|
||
from database.keys import (
|
||
update_key_post_creation_snapshot,
|
||
update_key_renewal_snapshot,
|
||
)
|
||
from database.servers import cluster_name_exists, get_cluster_name_for_server_name
|
||
from database.users import get_trial
|
||
from logger import logger
|
||
|
||
from .errors import NotFoundError, ValidationError
|
||
|
||
|
||
if TYPE_CHECKING:
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
|
||
@dataclass
|
||
class RenewalPricing:
|
||
"""Расчёт цены продления (без фактического продления)."""
|
||
|
||
base_price_rub: int
|
||
discount_rub: int
|
||
final_price_rub: int
|
||
coupon_id: int | None
|
||
applied_coupon_code: str | None
|
||
total_gb: int
|
||
balance: float
|
||
required_amount: int
|
||
payment_required: bool
|
||
duration_days: int
|
||
selected_device_limit: int | None
|
||
selected_traffic_limit: int | None
|
||
|
||
|
||
@dataclass
|
||
class RenewalResult:
|
||
"""Результат фактического продления."""
|
||
|
||
ok: bool
|
||
client_id: str
|
||
tariff_id: int
|
||
charged_rub: int
|
||
balance_rub: float
|
||
new_expiry_time: int
|
||
base_price_rub: int = 0
|
||
discount_rub: int = 0
|
||
final_price_rub: int = 0
|
||
applied_coupon_code: str | None = None
|
||
|
||
|
||
async def resolve_cluster_name(session: AsyncSession, server_or_cluster: str) -> str | None:
|
||
"""Определяет имя кластера: если имя — уже кластер, возвращаем его;
|
||
иначе ищем сервер с таким именем и отдаём его ``cluster_name``."""
|
||
if await cluster_name_exists(session, server_or_cluster):
|
||
return server_or_cluster
|
||
return await get_cluster_name_for_server_name(session, server_or_cluster)
|
||
|
||
|
||
def normalize_expiry_ms(raw_value: int | float | None) -> int:
|
||
"""Нормализует таймстамп истечения в миллисекунды.
|
||
|
||
Единая реализация — обрабатывает секунды, миллисекунды и микросекунды.
|
||
"""
|
||
if not raw_value:
|
||
return 0
|
||
value = int(raw_value)
|
||
if value > 10**13:
|
||
value //= 1000
|
||
elif value < 10**10:
|
||
value *= 1000
|
||
return value
|
||
|
||
|
||
def _resolve_effective_limits(
|
||
tariff: dict[str, Any],
|
||
selected_device_limit: int | None,
|
||
selected_traffic_limit: int | None,
|
||
) -> tuple[int | None, int | None]:
|
||
"""Определяет финальные device/traffic лимиты по тарифу и выбору пользователя."""
|
||
new_tariff_device = tariff.get("device_limit")
|
||
new_tariff_traffic = tariff.get("traffic_limit")
|
||
|
||
if new_tariff_device is None:
|
||
final_device = None
|
||
elif selected_device_limit is not None:
|
||
final_device = int(selected_device_limit)
|
||
else:
|
||
final_device = new_tariff_device
|
||
|
||
if new_tariff_traffic is None:
|
||
final_traffic = None
|
||
elif selected_traffic_limit is not None:
|
||
final_traffic = int(selected_traffic_limit)
|
||
else:
|
||
final_traffic = int(new_tariff_traffic)
|
||
|
||
return final_device, final_traffic
|
||
|
||
|
||
async def calculate_renewal_pricing(
|
||
session: AsyncSession,
|
||
billing_user_id: int,
|
||
key_email: str,
|
||
tariff_id: int,
|
||
coupon_code: str | None = None,
|
||
) -> RenewalPricing:
|
||
"""Считает цену продления без фактического выполнения.
|
||
|
||
Raises: NotFoundError, ValidationError
|
||
"""
|
||
from services.coupons import resolve_percent_coupon
|
||
|
||
tariff = await get_tariff_by_id(session, int(tariff_id))
|
||
if not tariff or not tariff.get("is_active", True):
|
||
raise NotFoundError("Тариф не найден")
|
||
|
||
duration_days = int(tariff.get("duration_days") or 0)
|
||
if duration_days <= 0:
|
||
raise ValidationError("Некорректная длительность тарифа")
|
||
|
||
key_details = await get_key_details(session, key_email)
|
||
if not key_details:
|
||
raise NotFoundError("Подписка не найдена")
|
||
|
||
selected_device = key_details.get("selected_device_limit")
|
||
selected_traffic = key_details.get("selected_traffic_limit")
|
||
sel_dev_int = int(selected_device) if selected_device is not None else None
|
||
sel_trf_int = int(selected_traffic) if selected_traffic is not None else None
|
||
|
||
from services.tariffs import calculate_config_price as calc_price_buy
|
||
|
||
total_price_rub = int(
|
||
calc_price_buy(
|
||
tariff=tariff,
|
||
selected_device_limit=sel_dev_int,
|
||
selected_traffic_gb=sel_trf_int,
|
||
)
|
||
)
|
||
if total_price_rub <= 0:
|
||
raise ValidationError("Некорректная стоимость продления")
|
||
|
||
final_price_rub, discount_rub, coupon_id, applied_code = await resolve_percent_coupon(
|
||
session=session,
|
||
billing_user_id=billing_user_id,
|
||
base_price_rub=total_price_rub,
|
||
coupon_code=coupon_code,
|
||
)
|
||
|
||
from services.tariffs.tariff_display import GB, get_effective_limits_for_key
|
||
|
||
_, traffic_bytes = await get_effective_limits_for_key(
|
||
session=session,
|
||
tariff_id=int(tariff_id),
|
||
selected_device_limit=sel_dev_int,
|
||
selected_traffic_gb=sel_trf_int if sel_trf_int is not None else 0,
|
||
)
|
||
total_gb = int(traffic_bytes / GB) if traffic_bytes else 0
|
||
|
||
balance = float(await get_balance(session, billing_user_id))
|
||
required = int(max(0, ceil(float(final_price_rub) - balance)))
|
||
|
||
return RenewalPricing(
|
||
base_price_rub=total_price_rub,
|
||
discount_rub=discount_rub,
|
||
final_price_rub=final_price_rub,
|
||
coupon_id=coupon_id,
|
||
applied_coupon_code=applied_code,
|
||
total_gb=total_gb,
|
||
balance=balance,
|
||
required_amount=required,
|
||
payment_required=required > 0,
|
||
duration_days=duration_days,
|
||
selected_device_limit=sel_dev_int,
|
||
selected_traffic_limit=sel_trf_int,
|
||
)
|
||
|
||
|
||
async def execute_renewal(
|
||
session: AsyncSession,
|
||
billing_user_id: int,
|
||
client_id: str,
|
||
key_email: str,
|
||
key_server_id: str,
|
||
tariff_id: int,
|
||
new_expiry_time: int,
|
||
total_gb: int,
|
||
cost: float,
|
||
selected_device_limit: int | None = None,
|
||
selected_traffic_limit: int | None = None,
|
||
selected_price_rub: int | None = None,
|
||
coupon_id: int | None = None,
|
||
) -> RenewalResult:
|
||
"""Выполняет продление ключа на кластере и обновляет БД.
|
||
|
||
Не отправляет сообщений в Telegram — это делает вызывающий код.
|
||
Raises: NotFoundError, ValidationError
|
||
"""
|
||
tariff = await get_tariff_by_id(session, tariff_id)
|
||
if not tariff:
|
||
raise NotFoundError(f"Тариф с id={tariff_id} не найден")
|
||
|
||
key_info = await get_key_details(session, key_email)
|
||
if not key_info:
|
||
raise NotFoundError(f"Ключ {client_id} не найден в БД")
|
||
|
||
final_device, final_traffic = _resolve_effective_limits(
|
||
tariff,
|
||
selected_device_limit,
|
||
selected_traffic_limit,
|
||
)
|
||
|
||
from services.operations import renew_key_in_cluster
|
||
from services.tariffs.tariff_display import GB, get_effective_limits_for_key
|
||
|
||
if tariff.get("configurable"):
|
||
sel_trf = int(final_traffic) if final_traffic is not None else None
|
||
sel_dev = int(final_device) if final_device is not None else None
|
||
device_eff, traffic_bytes_eff = await get_effective_limits_for_key(
|
||
session=session,
|
||
tariff_id=tariff_id,
|
||
selected_device_limit=sel_dev,
|
||
selected_traffic_gb=sel_trf,
|
||
)
|
||
traffic_gb_eff = int(traffic_bytes_eff / GB) if traffic_bytes_eff else 0
|
||
total_gb = traffic_gb_eff
|
||
else:
|
||
device_eff = final_device
|
||
traffic_gb_eff = int(final_traffic) if final_traffic is not None else 0
|
||
total_gb = traffic_gb_eff
|
||
|
||
current_subgroup = None
|
||
try:
|
||
cur_tariff_id = key_info.get("tariff_id")
|
||
if cur_tariff_id:
|
||
cur_tariff = await get_tariff_by_id(session, int(cur_tariff_id))
|
||
if cur_tariff:
|
||
current_subgroup = cur_tariff.get("subgroup_title")
|
||
except Exception as e:
|
||
logger.warning("[Keys] Ошибка получения subgroup текущего тарифа: {}", e)
|
||
|
||
target_subgroup = tariff.get("subgroup_title")
|
||
|
||
cluster_id = await resolve_cluster_name(session, key_server_id)
|
||
if not cluster_id:
|
||
raise NotFoundError(f"Кластер для {key_server_id} не найден")
|
||
|
||
await renew_key_in_cluster(
|
||
cluster_id=cluster_id,
|
||
email=key_email,
|
||
client_id=client_id,
|
||
new_expiry_time=new_expiry_time,
|
||
total_gb=total_gb,
|
||
session=session,
|
||
hwid_device_limit=device_eff,
|
||
reset_traffic=True,
|
||
target_subgroup=target_subgroup,
|
||
old_subgroup=current_subgroup,
|
||
plan=tariff_id,
|
||
)
|
||
|
||
key_row = await get_key_details(session, key_email)
|
||
effective_client_id = key_row["client_id"] if key_row else client_id
|
||
|
||
await update_key_expiry(session, effective_client_id, new_expiry_time)
|
||
|
||
new_dev = tariff.get("device_limit")
|
||
new_trf = tariff.get("traffic_limit")
|
||
|
||
if tariff.get("configurable"):
|
||
await update_key_renewal_snapshot(
|
||
session,
|
||
key_email,
|
||
tariff_id=tariff_id,
|
||
apply_limits=False,
|
||
)
|
||
else:
|
||
await update_key_renewal_snapshot(
|
||
session,
|
||
key_email,
|
||
tariff_id=tariff_id,
|
||
selected_device_limit=None if new_dev is None else new_dev,
|
||
current_device_limit=None if new_dev is None else final_device,
|
||
selected_traffic_limit=None if new_trf is None else new_trf,
|
||
current_traffic_limit=None if new_trf is None else final_traffic,
|
||
apply_limits=True,
|
||
)
|
||
await update_balance(session, billing_user_id, -cost)
|
||
|
||
if tariff.get("configurable"):
|
||
cfg = normalize_tariff_config(tariff)
|
||
raw_device_opts = cfg.get("device_options") or tariff.get("device_options") or []
|
||
raw_traffic_opts = cfg.get("traffic_options_gb") or tariff.get("traffic_options_gb") or []
|
||
has_device = len([v for v in raw_device_opts if _try_int(v) is not None]) > 1
|
||
has_traffic = len([v for v in raw_traffic_opts if _try_int(v) is not None]) > 1
|
||
|
||
await save_key_config_with_mode(
|
||
session=session,
|
||
email=key_email,
|
||
selected_devices=final_device,
|
||
selected_traffic_gb=final_traffic,
|
||
total_price=int(selected_price_rub or cost),
|
||
has_device_choice=has_device,
|
||
has_traffic_choice=has_traffic,
|
||
config_mode="renewal",
|
||
)
|
||
if has_device or has_traffic:
|
||
await reset_key_current_limits_to_selected(session, effective_client_id)
|
||
|
||
if coupon_id is not None:
|
||
await mark_coupon_used(session, coupon_id, billing_user_id)
|
||
|
||
new_balance = float(await get_balance(session, billing_user_id))
|
||
|
||
return RenewalResult(
|
||
ok=True,
|
||
client_id=effective_client_id,
|
||
tariff_id=tariff_id,
|
||
charged_rub=int(cost),
|
||
balance_rub=new_balance,
|
||
new_expiry_time=new_expiry_time,
|
||
base_price_rub=int(selected_price_rub or cost),
|
||
final_price_rub=int(cost),
|
||
)
|
||
|
||
|
||
def _try_int(v: Any) -> int | None:
|
||
try:
|
||
return int(v)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
@dataclass
|
||
class CreatedVpnKey:
|
||
"""Результат headless-создания ключа (без UI-ответа)."""
|
||
|
||
client_id: str
|
||
email: str
|
||
cluster_id: str
|
||
final_link: str
|
||
key_record: dict
|
||
price_charged: int
|
||
|
||
|
||
async def create_vpn_key_headless(
|
||
session: AsyncSession,
|
||
tg_id: int,
|
||
expiry_time: datetime,
|
||
*,
|
||
plan: int | None = None,
|
||
selected_device_limit: int | None = None,
|
||
selected_traffic_gb: int | None = None,
|
||
selected_price_rub: int | None = None,
|
||
skip_balance_charge: bool = False,
|
||
is_trial: bool = False,
|
||
forced_cluster: str | None = None,
|
||
) -> CreatedVpnKey:
|
||
"""Создаёт VPN-ключ для пользователя без aiogram/FSM зависимостей.
|
||
|
||
Используется из service-слоя (gift redemption, web tariff purchase, webhook
|
||
completion) — везде, где нет Message/CallbackQuery. Доменная логика та же,
|
||
что и у `handlers.keys.key_mode.key_cluster_mode`, но без построения
|
||
клавиатуры и отправки ответа.
|
||
|
||
Raises:
|
||
NotFoundError: пользователь не найден.
|
||
ValidationError: не удалось определить кластер или создать ключ.
|
||
"""
|
||
from handlers.utils import generate_random_email
|
||
from services.clusters import select_cluster
|
||
from services.operations import create_key_on_cluster
|
||
from services.tariffs.tariff_display import (
|
||
get_effective_limits_for_key,
|
||
resolve_price_to_charge,
|
||
)
|
||
|
||
owner = await resolve_user_optional(session, tg_id)
|
||
if owner is None:
|
||
raise NotFoundError(f"Пользователь не найден: {tg_id}")
|
||
|
||
key_name = await generate_random_email(session=session)
|
||
client_id = str(uuid.uuid4())
|
||
email = key_name.lower()
|
||
expiry_timestamp = int(expiry_time.timestamp() * 1000)
|
||
|
||
device_limit, traffic_limit_bytes = await get_effective_limits_for_key(
|
||
session=session,
|
||
tariff_id=plan,
|
||
selected_device_limit=selected_device_limit,
|
||
selected_traffic_gb=selected_traffic_gb,
|
||
)
|
||
if device_limit is None:
|
||
device_limit = 0
|
||
if traffic_limit_bytes is None:
|
||
traffic_limit_bytes = 0
|
||
|
||
if forced_cluster:
|
||
cluster_id = forced_cluster
|
||
else:
|
||
cluster_result = await select_cluster(session)
|
||
cluster_id = cluster_result.cluster_name
|
||
|
||
if selected_price_rub is not None:
|
||
price_to_charge = int(selected_price_rub)
|
||
else:
|
||
resolved = await resolve_price_to_charge(session, {})
|
||
price_to_charge = int(resolved or 0)
|
||
|
||
await create_key_on_cluster(
|
||
cluster_id=cluster_id,
|
||
tg_id=tg_id,
|
||
client_id=client_id,
|
||
email=email,
|
||
expiry_timestamp=expiry_timestamp,
|
||
plan=plan,
|
||
session=session,
|
||
hwid_limit=device_limit,
|
||
traffic_limit_bytes=traffic_limit_bytes,
|
||
is_trial=is_trial,
|
||
)
|
||
logger.info(f"[Key Creation] Ключ создан на кластере {cluster_id} для пользователя {tg_id}")
|
||
|
||
await update_key_post_creation_snapshot(
|
||
session,
|
||
user_id=owner.id,
|
||
email=email,
|
||
selected_device_limit=selected_device_limit,
|
||
selected_traffic_limit=selected_traffic_gb,
|
||
selected_price_rub=price_to_charge,
|
||
)
|
||
|
||
key_record = await get_key_details(session, email)
|
||
if not key_record:
|
||
raise ValidationError(f"Ключ не найден после создания: {email}")
|
||
final_link = key_record.get("link", "") or ""
|
||
|
||
if is_trial:
|
||
trial_status = await get_trial(session, tg_id)
|
||
if trial_status in (0, -1):
|
||
await update_trial(session, tg_id, 1)
|
||
|
||
if price_to_charge and not skip_balance_charge:
|
||
logger.info(f"[Key Creation] Списание с баланса user={tg_id}: -{price_to_charge} ₽")
|
||
await update_balance(session, tg_id, -int(price_to_charge))
|
||
elif skip_balance_charge:
|
||
logger.info(f"[Key Creation] Пропуск списания (skip_balance_charge) user={tg_id}")
|
||
else:
|
||
logger.info(f"[Key Creation] Списание не требуется (price=0) user={tg_id}")
|
||
|
||
return CreatedVpnKey(
|
||
client_id=client_id,
|
||
email=email,
|
||
cluster_id=cluster_id,
|
||
final_link=final_link,
|
||
key_record=key_record,
|
||
price_charged=int(price_to_charge or 0),
|
||
)
|