diff --git a/bot/services/tariff_worker.py b/bot/services/tariff_worker.py index 022a6fa..9da6974 100644 --- a/bot/services/tariff_worker.py +++ b/bot/services/tariff_worker.py @@ -45,7 +45,7 @@ class TariffTrafficWorker: await self.traffic_period_tick(session) await session.commit() async with self.session_factory() as session: - await self.throttle_recovery_tick(session) + await self.legacy_throttle_recovery_tick(session) await session.commit() except Exception: logging.exception("TariffTrafficWorker tick failed") @@ -74,10 +74,13 @@ class TariffTrafficWorker: continue panel_data = await self.panel_service.get_user_by_uuid(sub.panel_user_uuid, log_response=False) or {} used, limit, panel_strategy = self.subscription_service._extract_panel_traffic_details(panel_data) + panel_status = str(panel_data.get("status") or "").upper() if used is not None and used != sub.traffic_used_bytes: sub.traffic_used_bytes = used if limit is not None and limit != sub.traffic_limit_bytes: sub.traffic_limit_bytes = limit + if panel_status and panel_status != (sub.status_from_panel or "").upper(): + sub.status_from_panel = panel_status if tariff.billing_model == "period": await self._ensure_period_reset_strategy(sub, tariff, limit, panel_strategy) @@ -103,7 +106,6 @@ class TariffTrafficWorker: payload = self.subscription_service._build_panel_update_payload( panel_user_uuid=sub.panel_user_uuid, expire_at=sub.end_date, - status="ACTIVE", traffic_limit_bytes=traffic_limit_bytes, traffic_limit_strategy="MONTH", ) @@ -166,21 +168,21 @@ class TariffTrafficWorker: await self.bot.send_message(sub.user_id, text, reply_markup=markup) except Exception: logging.exception("Failed to send traffic warning to user %s", sub.user_id) - if ratio >= 1.0: - await self._throttle(session, sub, tariff) + if ratio >= 1.0 and not sub.is_throttled: + logging.info( + "Tariff traffic limit reached for user %s subscription %s. " + "Leaving access control to Remnawave status handling.", + sub.user_id, + sub.subscription_id, + ) - async def _throttle(self, session: AsyncSession, sub: Subscription, tariff) -> None: - if sub.is_throttled: - return - for squad_uuid in tariff.squad_uuids: - await self.panel_service.remove_users_from_internal_squad(squad_uuid, [sub.panel_user_uuid]) - await subscription_dal.update_subscription( - session, - sub.subscription_id, - {"is_throttled": True, "status_from_panel": "THROTTLED_BY_BOT"}, - ) + async def legacy_throttle_recovery_tick(self, session: AsyncSession) -> None: + """Recover subscriptions throttled by older bot versions. - async def throttle_recovery_tick(self, session: AsyncSession) -> None: + Current Remnawave versions enforce exhausted user traffic limits by + switching the user status to LIMITED, so new ticks must not remove users + from Internal Squads. + """ result = await session.execute( select(Subscription).where( Subscription.is_active == True, diff --git a/docs/tariffs.md b/docs/tariffs.md index 86ac980..fc3fc30 100644 --- a/docs/tariffs.md +++ b/docs/tariffs.md @@ -1,21 +1,23 @@ # Тарифы 2.0 -Бот поддерживает каталог тарифов в JSON-файле. Путь задается через `TARIFFS_CONFIG_PATH`, по умолчанию `config/tariffs.json`. +Бот поддерживает каталог тарифов в JSON-файле. Путь задается через `TARIFFS_CONFIG_PATH`, по умолчанию используется `config/tariffs.json`. -Если файл отсутствует, включается legacy fallback: используются старые `.env` поля `RUB_PRICE_*`, `STARS_PRICE_*`, `USER_TRAFFIC_LIMIT_GB`, `USER_SQUAD_UUIDS`, а также старый режим `TRAFFIC_PACKAGES`. +Если файл отсутствует, включается legacy-режим: используются старые `.env` поля `RUB_PRICE_*`, `STARS_PRICE_*`, `USER_TRAFFIC_LIMIT_GB`, `USER_SQUAD_UUIDS`, а также старый режим продажи трафика через `TRAFFIC_PACKAGES`. ## Конфиг -См. пример: [`config/tariffs.example.json`](../config/tariffs.example.json). +Пример конфига: [`config/tariffs.example.json`](../config/tariffs.example.json). Основные поля: | Поле | Описание | | --- | --- | -| `default_tariff` | Тариф для миграции существующих подписок и выбора по умолчанию | -| `topup_packages_default` | Пакеты докупки для period-тарифов без собственных пакетов | -| `tariffs[].billing_model` | `period` или `traffic` | -| `tariffs[].squad_uuids` | Internal squads Remnawave для тарифа | +| `default_tariff` | Тариф по умолчанию для миграции существующих подписок и первичного выбора | +| `topup_packages_default` | Пакеты докупки трафика для period-тарифов без собственных пакетов | +| `tariffs[].billing_model` | Модель тарификации: `period` или `traffic` | +| `tariffs[].squad_uuids` | Internal Squads Remnawave, которые получает пользователь на этом тарифе | +| `tariffs[].hwid_device_limit` | Базовый лимит HWID-устройств для тарифа; `0` означает без ограничений, отсутствие поля использует `USER_HWID_DEVICE_LIMIT` | +| `tariffs[].hwid_device_packages` | Пакеты докупки HWID-устройств, например `{ "count": 1, "price": 99 }` | | `prices_rub` / `prices_stars` | Цены period-тарифов по месяцам | | `traffic_packages` | Пакеты GB для traffic-тарифов | @@ -24,12 +26,12 @@ Period-тариф продает доступ на срок и лимит трафика с календарным ежемесячным сбросом. - `monthly_gb` превращается в `tier_baseline_bytes`. -- Докупленные пакеты хранятся в `topup_balance_bytes`. -- В Remnawave пушится `trafficLimitBytes = tier_baseline_bytes + topup_balance_bytes`. -- Для period-тарифов бот выставляет `trafficLimitStrategy = MONTH`, а дальнейший reset делает сама панель. +- Докупленные пакеты трафика хранятся в `topup_balance_bytes`. +- В Remnawave отправляется `trafficLimitBytes = tier_baseline_bytes + topup_balance_bytes`. +- Для period-тарифов бот выставляет `trafficLimitStrategy = MONTH`, а дальнейший сброс выполняет сама панель. - Дата сброса больше не считается в боте. -- Если покупка или продление были в середине месяца, сброс всё равно произойдёт по правилам панели для `MONTH`. -- Бот только меняет лимиты в GB и следит за предупреждениями/throttle на основе текущего usage из панели. +- Если покупка или продление были в середине месяца, сброс все равно произойдет по правилам панели для `MONTH`. +- Бот меняет только лимиты в GB и следит за предупреждениями на основе текущего usage из панели. ## Traffic-Тариф @@ -41,42 +43,57 @@ Traffic-тариф продает GB без срока действия. - Новая покупка добавляет GB к фактическому остатку: `limit = used + remaining + purchased`. - Доступ ограничивается только при исчерпании купленного трафика. +## HWID-Устройства + +Тарифы поддерживают лимит HWID-устройств и платную докупку устройств. + +- При покупке или продлении тарифа бот отправляет в Remnawave `hwidDeviceLimit`. +- `subscriptions.hwid_device_limit` хранит базовый лимит тарифа. +- `subscriptions.extra_hwid_devices` хранит количество докупленных устройств. +- Эффективный лимит равен `hwid_device_limit + extra_hwid_devices`. +- Если базовый лимит равен `0`, это безлимит; докупка не нужна, а в панель отправляется `0`. +- Докупка устройств использует `sale_mode=hwid_devices`. +- Количество купленных устройств сохраняется в `payments.purchased_hwid_devices`. +- История докупок пишется в `hwid_device_purchases`. +- Докупка доступна в Web App через `/api/devices/topup-options` и `/api/payments`. +- Докупка доступна в Telegram-боте из раздела устройств. +- При смене тарифа базовый HWID-лимит берется из нового тарифа, а уже докупленные устройства сохраняются. + ## Смена Тарифа -Смена пишется в `tariff_changes`. +Смена тарифа пишется в `tariff_changes`. - `period -> period`: расчет идет от `effective_monthly_price_rub`; пересчет дней использует `floor`. -- `period -> traffic`: остаток оплаченных дней конвертируется в GB по `conversion_rate_rub_per_gb` или минимальной цене GB в RUB-пакетах. -- `traffic -> period`: пользователь покупает период, а остаток GB сохраняется как топ-ап поверх нового тарифа. +- `period -> traffic`: остаток оплаченных дней конвертируется в GB по `conversion_rate_rub_per_gb` или по минимальной цене GB в RUB-пакетах. +- `traffic -> period`: пользователь покупает период, а остаток GB сохраняется как top-up поверх нового тарифа. +- При смене тарифа обновляются Internal Squads, лимит трафика, стратегия сброса и базовый HWID-лимит. ## Платежи Новые платежи сохраняют: -- `sale_mode`: `subscription`, `traffic_package`, `topup`, `tariff_upgrade`; +- `sale_mode`: `subscription`, `traffic_package`, `topup`, `tariff_upgrade`, `hwid_devices`; - `tariff_key`; -- `purchased_gb` для GB-покупок. +- `purchased_gb` для GB-покупок; +- `purchased_hwid_devices` для докупки HWID-устройств. -Legacy поле `subscription_duration_months` остается для совместимости. +Legacy-поле `subscription_duration_months` остается для совместимости с существующими платежными обработчиками. + +## Поведение При Исчерпании Трафика + +Remnawave сама ограничивает пользователя при достижении `trafficLimitBytes`: панель переводит пользователя в статус `LIMITED`. Бот не должен удалять пользователя из Internal Squads при 100% использования трафика. + +Текущее поведение воркера: + +- синхронизирует из панели `status`, `trafficLimitBytes`, `usedTrafficBytes` и `trafficLimitStrategy`; +- отправляет предупреждения на уровнях из `TARIFF_TRAFFIC_WARNING_LEVELS` (по умолчанию `85,90,95`); +- оставляет блокировку и разблокировку доступа штатной логике Remnawave. ## Воркеры -`TariffTrafficWorker` запускается, только если активен `tariffs.json`. +`TariffTrafficWorker` запускается только при активном `tariffs.json`. -- Раз в несколько минут синхронизирует `trafficLimitStrategy = MONTH` для period-тарифов, если панель ещё не переключена. -- Отправляет/дедуплицирует уровни предупреждений из `TARIFF_TRAFFIC_WARNING_LEVELS` (по умолчанию `85,90,95`) через `traffic_warnings`. -- При 100% удаляет пользователя из squad-ов тарифа и ставит `is_throttled`. -- Возвращает пользователя в squad-ы, когда лимит снова больше использованного трафика. - - -## HWID devices - -Tariff config now supports per-tariff device limits and paid device add-ons: - -- `tariffs[].hwid_device_limit`: base HWID device limit for the tariff. `0` means unlimited. Missing/null falls back to `USER_HWID_DEVICE_LIMIT`. -- `tariffs[].hwid_device_packages`: paid add-on packages, for example `{ "count": 1, "price": 99 }` in `rub` or `stars`. -- The bot stores the tariff base limit in `subscriptions.hwid_device_limit` and purchased add-ons in `subscriptions.extra_hwid_devices`. -- Effective panel value is `hwid_device_limit + extra_hwid_devices`; if the base limit is `0`, it remains unlimited and add-ons are ignored. -- Device add-ons use `sale_mode=hwid_devices`, persist `payments.purchased_hwid_devices`, and update Remnawave via `hwidDeviceLimit` after payment. -- Add-ons are available in Web App via `/api/devices/topup-options` + `/api/payments`, and in Telegram via the devices section. -- On tariff change, the base HWID limit is taken from the new tariff, while already purchased extra devices are preserved. +- Раз в несколько минут синхронизирует `trafficLimitStrategy = MONTH` для period-тарифов, если панель еще не переключена. +- При синхронизации стратегии не отправляет `status=ACTIVE`, чтобы случайно не снять статус `LIMITED`, выставленный Remnawave. +- Дедуплицирует предупреждения через таблицу `traffic_warnings`. +- Для traffic-тарифов дедупликация предупреждений учитывает текущий `trafficLimitBytes`, чтобы новая покупка трафика могла создать новый набор предупреждений. diff --git a/tests/test_tariff_worker.py b/tests/test_tariff_worker.py index 3885cbe..4e99505 100644 --- a/tests/test_tariff_worker.py +++ b/tests/test_tariff_worker.py @@ -4,7 +4,7 @@ import unittest from datetime import datetime, timedelta, timezone from pathlib import Path from types import SimpleNamespace -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from bot.services.panel_api_service import PanelApiService from bot.services.subscription_service import SubscriptionService @@ -76,3 +76,51 @@ class TariffWorkerTests(unittest.IsolatedAsyncioTestCase): update_payload = panel_service.update_user_details_on_panel.await_args.args[1] self.assertEqual(update_payload["trafficLimitStrategy"], "MONTH") self.assertEqual(update_payload["trafficLimitBytes"], sub.traffic_limit_bytes) + self.assertNotIn("status", update_payload) + + async def test_limit_reached_does_not_remove_user_from_squad(self): + with tempfile.TemporaryDirectory() as tmpdir: + config_path = Path(tmpdir) / "tariffs.json" + config_path.write_text(json.dumps(_tariffs_config_payload()), encoding="utf-8") + + settings = Settings( + _env_file=None, + BOT_TOKEN="token", + POSTGRES_USER="app_user", + POSTGRES_PASSWORD="app_password", + TARIFFS_CONFIG_PATH=str(config_path), + TARIFF_TRAFFIC_WARNING_LEVELS="101", + ) + panel_service = AsyncMock(spec=PanelApiService) + panel_service.remove_users_from_internal_squad = AsyncMock(return_value=True) + subscription_service = SubscriptionService(settings, panel_service) + worker = TariffTrafficWorker( + settings=settings, + session_factory=SimpleNamespace(), + panel_service=panel_service, + subscription_service=subscription_service, + ) + + sub = SimpleNamespace( + subscription_id=1, + user_id=123, + panel_user_uuid="panel-uuid", + traffic_limit_bytes=100, + traffic_used_bytes=100, + is_throttled=False, + status_from_panel="ACTIVE", + ) + tariff = settings.tariffs_config.require("standard") + + with patch("bot.services.tariff_worker.tariff_dal.get_warning", new=AsyncMock(return_value=True)): + await worker._maybe_warn_or_throttle( + AsyncMock(), + sub, + tariff, + used=100, + limit=100, + warning_period_start=datetime.now(timezone.utc), + ) + + panel_service.remove_users_from_internal_squad.assert_not_awaited() + self.assertFalse(sub.is_throttled)