fix: use limited tag for mark exhausted traffic users

This commit is contained in:
3252a8
2026-05-03 22:46:12 +03:00
parent aa5a9496ed
commit 63784bc036
3 changed files with 119 additions and 52 deletions
+16 -14
View File
@@ -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)
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,
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,
{"is_throttled": True, "status_from_panel": "THROTTLED_BY_BOT"},
)
async def throttle_recovery_tick(self, session: AsyncSession) -> None:
async def legacy_throttle_recovery_tick(self, session: AsyncSession) -> None:
"""Recover subscriptions throttled by older bot versions.
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,
+53 -36
View File
@@ -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`, чтобы новая покупка трафика могла создать новый набор предупреждений.
+49 -1
View File
@@ -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)