From 386b9ae9986a56070a7a13664fbacae77b90c349 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 8 Dec 2025 02:44:53 +0300 Subject: [PATCH] Add configurable user tags for trial and paid subscriptions --- app/config.py | 43 +++++++++++++++++++++++-- app/services/subscription_service.py | 28 +++++++++++++--- app/services/system_settings_service.py | 20 ++++++++++++ 3 files changed, 85 insertions(+), 6 deletions(-) diff --git a/app/config.py b/app/config.py index cccc32fc..1bf109b2 100644 --- a/app/config.py +++ b/app/config.py @@ -19,6 +19,8 @@ DEFAULT_DISPLAY_NAME_BANNED_KEYWORDS = [ "joingroup", ] +USER_TAG_PATTERN = re.compile(r"^[A-Z0-9_]{1,16}$") + logger = logging.getLogger(__name__) @@ -88,6 +90,7 @@ class Settings(BaseSettings): TRIAL_ADD_REMAINING_DAYS_TO_PAID: bool = False TRIAL_PAYMENT_ENABLED: bool = False TRIAL_ACTIVATION_PRICE: int = 0 + TRIAL_USER_TAG: Optional[str] = None DEFAULT_TRAFFIC_LIMIT_GB: int = 100 DEFAULT_DEVICE_LIMIT: int = 1 DEFAULT_TRAFFIC_RESET_STRATEGY: str = "MONTH" @@ -119,6 +122,7 @@ class Settings(BaseSettings): PRICE_90_DAYS: int = 269000 PRICE_180_DAYS: int = 499000 PRICE_360_DAYS: int = 899000 + PAID_SUBSCRIPTION_USER_TAG: Optional[str] = None PRICE_TRAFFIC_5GB: int = 2000 PRICE_TRAFFIC_10GB: int = 3500 @@ -776,13 +780,48 @@ class Settings(BaseSettings): def kopeks_to_rubles(self, kopeks: int) -> float: return kopeks / 100 - + def rubles_to_kopeks(self, rubles: float) -> int: return int(rubles * 100) - + + @staticmethod + def _normalize_user_tag(value: Optional[str], setting_name: str) -> Optional[str]: + if value is None: + return None + + cleaned = str(value).strip().upper() + if not cleaned: + return None + + if len(cleaned) > 16: + logger.warning( + "Некорректная длина %s: максимум 16 символов, получено %s", + setting_name, + len(cleaned), + ) + return None + + if not USER_TAG_PATTERN.fullmatch(cleaned): + logger.warning( + "Некорректный формат %s: допустимы только A-Z, 0-9 и подчёркивание", + setting_name, + ) + return None + + return cleaned + def get_trial_warning_hours(self) -> int: return self.TRIAL_WARNING_HOURS + def get_trial_user_tag(self) -> Optional[str]: + return self._normalize_user_tag(self.TRIAL_USER_TAG, "TRIAL_USER_TAG") + + def get_paid_subscription_user_tag(self) -> Optional[str]: + return self._normalize_user_tag( + self.PAID_SUBSCRIPTION_USER_TAG, + "PAID_SUBSCRIPTION_USER_TAG", + ) + def get_bot_username(self) -> Optional[str]: username = getattr(self, "BOT_USERNAME", None) if not username: diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index 623aeaf9..f7a29e2f 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -132,6 +132,13 @@ class SubscriptionService: self._last_config_signature = config_signature + @staticmethod + def _resolve_user_tag(subscription: Subscription) -> Optional[str]: + if getattr(subscription, "is_trial", False): + return settings.get_trial_user_tag() + + return settings.get_paid_subscription_user_tag() + @property def is_configured(self) -> bool: return self._config_error is None @@ -173,7 +180,9 @@ class SubscriptionService: if not validation_success: logger.error(f"Ошибка валидации подписки для пользователя {user.telegram_id}") return None - + + user_tag = self._resolve_user_tag(subscription) + async with self.get_api_client() as api: hwid_limit = resolve_hwid_device_limit_for_payload(subscription) existing_users = await api.get_user_by_telegram_id(user.telegram_id) @@ -201,6 +210,9 @@ class SubscriptionService: active_internal_squads=subscription.connected_squads, ) + if user_tag is not None: + update_kwargs['tag'] = user_tag + if hwid_limit is not None: update_kwargs['hwid_device_limit'] = hwid_limit @@ -236,6 +248,9 @@ class SubscriptionService: active_internal_squads=subscription.connected_squads, ) + if user_tag is not None: + create_kwargs['tag'] = user_tag + if hwid_limit is not None: create_kwargs['hwid_device_limit'] = hwid_limit @@ -288,15 +303,17 @@ class SubscriptionService: is_actually_active = (subscription.status == SubscriptionStatus.ACTIVE.value and subscription.end_date > current_time) - if (subscription.status == SubscriptionStatus.ACTIVE.value and + if (subscription.status == SubscriptionStatus.ACTIVE.value and subscription.end_date <= current_time): - + subscription.status = SubscriptionStatus.EXPIRED.value subscription.updated_at = current_time await db.commit() is_actually_active = False logger.info(f"🔔 Статус подписки {subscription.id} автоматически изменен на 'expired'") - + + user_tag = self._resolve_user_tag(subscription) + async with self.get_api_client() as api: hwid_limit = resolve_hwid_device_limit_for_payload(subscription) @@ -314,6 +331,9 @@ class SubscriptionService: active_internal_squads=subscription.connected_squads, ) + if user_tag is not None: + update_kwargs['tag'] = user_tag + if hwid_limit is not None: update_kwargs['hwid_device_limit'] = hwid_limit diff --git a/app/services/system_settings_service.py b/app/services/system_settings_service.py index c8426e21..7b78034a 100644 --- a/app/services/system_settings_service.py +++ b/app/services/system_settings_service.py @@ -220,6 +220,7 @@ class BotConfigurationService: "PRICE_90_DAYS": "SUBSCRIPTION_PRICES", "PRICE_180_DAYS": "SUBSCRIPTION_PRICES", "PRICE_360_DAYS": "SUBSCRIPTION_PRICES", + "PAID_SUBSCRIPTION_USER_TAG": "SUBSCRIPTION_PRICES", "TRAFFIC_PACKAGES_CONFIG": "TRAFFIC_PACKAGES", "BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED": "SUBSCRIPTIONS_CORE", "BASE_PROMO_GROUP_PERIOD_DISCOUNTS": "SUBSCRIPTIONS_CORE", @@ -227,6 +228,7 @@ class BotConfigurationService: "DEFAULT_AUTOPAY_DAYS_BEFORE": "AUTOPAY", "MIN_BALANCE_FOR_AUTOPAY_KOPEKS": "AUTOPAY", "TRIAL_WARNING_HOURS": "TRIAL", + "TRIAL_USER_TAG": "TRIAL", "SUPPORT_USERNAME": "SUPPORT", "SUPPORT_MENU_ENABLED": "SUPPORT", "SUPPORT_SYSTEM_MODE": "SUPPORT", @@ -643,6 +645,24 @@ class BotConfigurationService: "warning": "Несовпадение ID блокирует обновление токена, предотвращая его подмену на другом боте.", "dependencies": "Результат вызова getMe() в Telegram Bot API", }, + "TRIAL_USER_TAG": { + "description": ( + "Тег, который бот передаст пользователю при активации триальной подписки в панели RemnaWave." + ), + "format": "До 16 символов: заглавные A-Z, цифры и подчёркивание.", + "example": "TRIAL_USER", + "warning": "Неверный формат будет проигнорирован при создании пользователя.", + "dependencies": "Активация триала и включенная интеграция с RemnaWave", + }, + "PAID_SUBSCRIPTION_USER_TAG": { + "description": ( + "Тег, который бот ставит пользователю при покупке платной подписки в панели RemnaWave." + ), + "format": "До 16 символов: заглавные A-Z, цифры и подчёркивание.", + "example": "PAID_USER", + "warning": "Если тег не задан или невалиден, существующий тег не будет изменён.", + "dependencies": "Оплата подписки и интеграция с RemnaWave", + }, } @classmethod