Files
Solo_bot/services/keys.py
T
2026-04-14 07:19:14 +00:00

479 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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),
)