redis cache/ webhook abuse/ separate streams for sending and requesting panels/ increased security and more
This commit is contained in:
+103
-8
@@ -1,14 +1,47 @@
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
from sqlalchemy import delete, func, select, text, update
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.cache_config import (
|
||||
KEY_COUNT_CACHE_TTL_SEC,
|
||||
KEY_DETAILS_CACHE_TTL_SEC,
|
||||
KEYS_LIST_CACHE_TTL_SEC,
|
||||
)
|
||||
from core.redis_cache import cache_delete, cache_get, cache_key, cache_set
|
||||
from database.models import Key, User
|
||||
from database.users import invalidate_user_snapshot
|
||||
from database.users import invalidate_profile_cache, invalidate_user_snapshot
|
||||
from logger import logger
|
||||
|
||||
|
||||
async def invalidate_key_details(email: str) -> None:
|
||||
await cache_delete(cache_key("key_details", email))
|
||||
|
||||
|
||||
async def invalidate_key_email(client_id: str) -> None:
|
||||
await cache_delete(cache_key("key_email", client_id))
|
||||
|
||||
|
||||
async def invalidate_keys_list(tg_id: int) -> None:
|
||||
await cache_delete(cache_key("keys_list", tg_id))
|
||||
await cache_delete(cache_key("key_count", tg_id))
|
||||
await invalidate_profile_cache(tg_id)
|
||||
|
||||
|
||||
async def invalidate_key_details_by_client_id(session: AsyncSession, client_id: str) -> None:
|
||||
email = await cache_get(cache_key("key_email", client_id))
|
||||
await cache_delete(cache_key("key_email", client_id))
|
||||
if email:
|
||||
await invalidate_key_details(str(email))
|
||||
else:
|
||||
res = await session.execute(select(Key.email).where(Key.client_id == client_id).limit(1))
|
||||
row = res.scalar_one_or_none()
|
||||
if row is not None:
|
||||
await invalidate_key_details(str(row))
|
||||
|
||||
|
||||
async def store_key(
|
||||
session: AsyncSession,
|
||||
tg_id: int,
|
||||
@@ -83,6 +116,8 @@ async def store_key(
|
||||
|
||||
await session.commit()
|
||||
invalidate_user_snapshot(tg_id)
|
||||
await invalidate_keys_list(tg_id)
|
||||
await invalidate_key_details(email)
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"❌ Ошибка при сохранении ключа: {e}")
|
||||
@@ -90,9 +125,29 @@ async def store_key(
|
||||
raise
|
||||
|
||||
|
||||
def _key_to_cache_dict(k: Key) -> dict:
|
||||
return {
|
||||
"email": k.email,
|
||||
"alias": k.alias,
|
||||
"client_id": k.client_id,
|
||||
"expiry_time": int(k.expiry_time) if k.expiry_time is not None else 0,
|
||||
"created_at": int(k.created_at) if k.created_at is not None else 0,
|
||||
"tariff_id": k.tariff_id,
|
||||
"server_id": k.server_id,
|
||||
"is_frozen": bool(k.is_frozen) if k.is_frozen is not None else False,
|
||||
}
|
||||
|
||||
|
||||
async def get_keys(session: AsyncSession, tg_id: int):
|
||||
ckey = cache_key("keys_list", tg_id)
|
||||
cached = await cache_get(ckey)
|
||||
if isinstance(cached, list):
|
||||
return [SimpleNamespace(**d) for d in cached]
|
||||
result = await session.execute(select(Key).where(Key.tg_id == tg_id))
|
||||
return result.scalars().all()
|
||||
rows = result.scalars().all()
|
||||
serialized = [_key_to_cache_dict(k) for k in rows]
|
||||
await cache_set(ckey, serialized, KEYS_LIST_CACHE_TTL_SEC)
|
||||
return rows
|
||||
|
||||
|
||||
async def get_all_keys(session: AsyncSession):
|
||||
@@ -107,7 +162,12 @@ async def get_key_by_server(session: AsyncSession, tg_id: int, client_id: str):
|
||||
|
||||
|
||||
async def get_key_details(session: AsyncSession, email: str) -> dict | None:
|
||||
"""Возвращает подробную информацию о ключе по email."""
|
||||
"""Возвращает подробную информацию о ключе по email. Горячие данные кэшируются в Redis."""
|
||||
ckey = cache_key("key_details", email)
|
||||
cached = await cache_get(ckey)
|
||||
if isinstance(cached, dict):
|
||||
return cached
|
||||
|
||||
stmt = select(Key, User).join(User, Key.tg_id == User.tg_id).where(Key.email == email)
|
||||
result = await session.execute(stmt)
|
||||
row = result.first()
|
||||
@@ -127,7 +187,7 @@ async def get_key_details(session: AsyncSession, email: str) -> dict | None:
|
||||
hours_left = time_left.seconds // 3600
|
||||
days_left_message = f"Осталось часов: <b>{hours_left}</b>"
|
||||
|
||||
return {
|
||||
out = {
|
||||
"key": key.key,
|
||||
"remnawave_link": key.remnawave_link,
|
||||
"server_id": key.server_id,
|
||||
@@ -151,18 +211,36 @@ async def get_key_details(session: AsyncSession, email: str) -> dict | None:
|
||||
"current_device_limit": key.current_device_limit,
|
||||
"current_traffic_limit": key.current_traffic_limit,
|
||||
}
|
||||
await cache_set(ckey, out, KEY_DETAILS_CACHE_TTL_SEC)
|
||||
if key.client_id:
|
||||
await cache_set(cache_key("key_email", key.client_id), email, KEY_DETAILS_CACHE_TTL_SEC)
|
||||
return out
|
||||
|
||||
|
||||
async def get_key_count(session: AsyncSession, tg_id: int) -> int:
|
||||
cached = await cache_get(cache_key("key_count", tg_id))
|
||||
if cached is not None:
|
||||
try:
|
||||
return int(cached)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
result = await session.execute(select(func.count()).select_from(Key).where(Key.tg_id == tg_id))
|
||||
return result.scalar() or 0
|
||||
count = result.scalar() or 0
|
||||
await cache_set(cache_key("key_count", tg_id), count, KEY_COUNT_CACHE_TTL_SEC)
|
||||
return count
|
||||
|
||||
|
||||
async def delete_key(session: AsyncSession, identifier: int | str, commit: bool = True):
|
||||
tg_id_for_cache = None
|
||||
email_for_cache = None
|
||||
if isinstance(identifier, str):
|
||||
res = await session.execute(select(Key.tg_id).where(Key.client_id == identifier).limit(1))
|
||||
tg_id_for_cache = res.scalar_one_or_none()
|
||||
res = await session.execute(
|
||||
select(Key.tg_id, Key.email).where(Key.client_id == identifier).limit(1)
|
||||
)
|
||||
row = res.first()
|
||||
if row:
|
||||
tg_id_for_cache, email_for_cache = row[0], row[1]
|
||||
await cache_delete(cache_key("key_email", identifier))
|
||||
else:
|
||||
tg_id_for_cache = identifier
|
||||
stmt = delete(Key).where(Key.tg_id == identifier if isinstance(identifier, int) else Key.client_id == identifier)
|
||||
@@ -171,12 +249,16 @@ async def delete_key(session: AsyncSession, identifier: int | str, commit: bool
|
||||
await session.commit()
|
||||
if tg_id_for_cache is not None:
|
||||
invalidate_user_snapshot(tg_id_for_cache)
|
||||
await invalidate_keys_list(tg_id_for_cache)
|
||||
if email_for_cache is not None:
|
||||
await invalidate_key_details(str(email_for_cache))
|
||||
logger.info(f"Ключ с идентификатором {identifier} удалён")
|
||||
|
||||
|
||||
async def update_key_expiry(session: AsyncSession, client_id: str, new_expiry_time: int):
|
||||
await session.execute(update(Key).where(Key.client_id == client_id).values(expiry_time=new_expiry_time))
|
||||
await session.commit()
|
||||
await invalidate_key_details_by_client_id(session, client_id)
|
||||
logger.info(f"Срок действия ключа {client_id} обновлён до {new_expiry_time}")
|
||||
|
||||
|
||||
@@ -188,6 +270,8 @@ async def get_client_id_by_email(session: AsyncSession, email: str):
|
||||
async def update_key_notified(session: AsyncSession, tg_id: int, client_id: str):
|
||||
await session.execute(update(Key).where(Key.tg_id == tg_id, Key.client_id == client_id).values(notified=True))
|
||||
await session.commit()
|
||||
await invalidate_keys_list(tg_id)
|
||||
await invalidate_key_details_by_client_id(session, client_id)
|
||||
|
||||
|
||||
async def mark_key_as_frozen(session: AsyncSession, tg_id: int, client_id: str, time_left: int):
|
||||
@@ -203,6 +287,8 @@ async def mark_key_as_frozen(session: AsyncSession, tg_id: int, client_id: str,
|
||||
),
|
||||
{"expiry": time_left, "tg_id": tg_id, "client_id": client_id},
|
||||
)
|
||||
await invalidate_keys_list(tg_id)
|
||||
await invalidate_key_details_by_client_id(session, client_id)
|
||||
|
||||
|
||||
async def mark_key_as_unfrozen(
|
||||
@@ -223,11 +309,14 @@ async def mark_key_as_unfrozen(
|
||||
),
|
||||
{"expiry": new_expiry_time, "tg_id": tg_id, "client_id": client_id},
|
||||
)
|
||||
await invalidate_keys_list(tg_id)
|
||||
await invalidate_key_details_by_client_id(session, client_id)
|
||||
|
||||
|
||||
async def update_key_tariff(session: AsyncSession, client_id: str, tariff_id: int):
|
||||
await session.execute(update(Key).where(Key.client_id == client_id).values(tariff_id=tariff_id))
|
||||
await session.commit()
|
||||
await invalidate_key_details_by_client_id(session, client_id)
|
||||
logger.info(f"Тариф ключа {client_id} обновлён на {tariff_id}")
|
||||
|
||||
|
||||
@@ -239,6 +328,7 @@ async def get_subscription_link(session: AsyncSession, email: str) -> str | None
|
||||
async def update_key_client_id(session: AsyncSession, email: str, new_client_id: str):
|
||||
await session.execute(update(Key).where(Key.email == email).values(client_id=new_client_id))
|
||||
await session.commit()
|
||||
await invalidate_key_details(email)
|
||||
logger.info(f"client_id обновлён для {email} -> {new_client_id}")
|
||||
|
||||
|
||||
@@ -246,7 +336,10 @@ async def update_key_link(session: AsyncSession, email: str, link: str) -> bool:
|
||||
q = update(Key).where(Key.email == email).values(key=link).returning(Key.client_id)
|
||||
res = await session.execute(q)
|
||||
await session.commit()
|
||||
return res.scalar_one_or_none() is not None
|
||||
ok = res.scalar_one_or_none() is not None
|
||||
if ok:
|
||||
await invalidate_key_details(email)
|
||||
return ok
|
||||
|
||||
|
||||
async def save_key_config_with_mode(
|
||||
@@ -280,6 +373,7 @@ async def save_key_config_with_mode(
|
||||
return
|
||||
|
||||
await session.execute(update(Key).where(Key.email == email).values(**values))
|
||||
await invalidate_key_details(email)
|
||||
|
||||
|
||||
async def reset_key_current_limits_to_selected(session: AsyncSession, client_id: str):
|
||||
@@ -296,4 +390,5 @@ async def reset_key_current_limits_to_selected(session: AsyncSession, client_id:
|
||||
{"client_id": client_id},
|
||||
)
|
||||
await session.commit()
|
||||
await invalidate_key_details_by_client_id(session, client_id)
|
||||
logger.info(f"Текущие лимиты ключа {client_id} сброшены к выбранным")
|
||||
|
||||
@@ -110,6 +110,30 @@ async def get_last_notification_time(session: AsyncSession, tg_id: int, notifica
|
||||
return None
|
||||
|
||||
|
||||
async def get_last_notification_times_bulk(
|
||||
session: AsyncSession, pairs: list[tuple[int, str]]
|
||||
) -> dict[tuple[int, str], int]:
|
||||
"""
|
||||
Один запрос: последние времена уведомлений для списка (tg_id, notification_type).
|
||||
Возвращает dict[(tg_id, notification_type)] -> timestamp_ms.
|
||||
"""
|
||||
if not pairs:
|
||||
return {}
|
||||
from sqlalchemy import tuple_
|
||||
|
||||
stmt = select(
|
||||
Notification.tg_id,
|
||||
Notification.notification_type,
|
||||
Notification.last_notification_time,
|
||||
).where(tuple_(Notification.tg_id, Notification.notification_type).in_(pairs))
|
||||
result = await session.execute(stmt)
|
||||
out = {}
|
||||
for tg_id, ntype, last_time in result.all():
|
||||
if last_time:
|
||||
out[(tg_id, ntype)] = int(last_time.timestamp() * 1000)
|
||||
return out
|
||||
|
||||
|
||||
async def check_hot_lead_discount(session: AsyncSession, tg_id: int) -> dict:
|
||||
try:
|
||||
result = await session.execute(
|
||||
|
||||
@@ -5,6 +5,8 @@ from sqlalchemy import and_, insert, select, update
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.cache_config import PAYMENT_PENDING_CACHE_TTL_SEC
|
||||
from core.redis_cache import cache_delete, cache_get, cache_key, cache_set
|
||||
from database.models import Payment
|
||||
from logger import logger
|
||||
|
||||
@@ -12,6 +14,42 @@ from logger import logger
|
||||
MOSCOW_TZ = timezone("Europe/Moscow")
|
||||
|
||||
|
||||
def _payment_cache_key(pid: str) -> str:
|
||||
return cache_key("payment_pending", pid)
|
||||
|
||||
|
||||
async def register_pending_payment(
|
||||
payment_id: str,
|
||||
tg_id: int,
|
||||
amount: float,
|
||||
payment_system: str,
|
||||
*,
|
||||
currency: str = "RUB",
|
||||
metadata: dict | None = None,
|
||||
original_amount: float | None = None,
|
||||
) -> bool:
|
||||
"""Регистрирует ожидающий платёж только в Redis. В БД пишем при success/fail из вебхука."""
|
||||
data = {
|
||||
"tg_id": tg_id,
|
||||
"amount": amount,
|
||||
"currency": currency,
|
||||
"status": "pending",
|
||||
"payment_system": payment_system,
|
||||
"payment_id": payment_id,
|
||||
"metadata": metadata,
|
||||
"original_amount": original_amount,
|
||||
}
|
||||
ok = await cache_set(_payment_cache_key(payment_id), data, PAYMENT_PENDING_CACHE_TTL_SEC)
|
||||
if ok:
|
||||
logger.debug(f"[Payments] Pending в кэше: payment_id={payment_id}, tg_id={tg_id}")
|
||||
return ok
|
||||
|
||||
|
||||
async def invalidate_payment_cache(payment_id: str) -> None:
|
||||
"""Вызвать после сохранения платежа в БД (success/fail) из вебхука."""
|
||||
await cache_delete(_payment_cache_key(payment_id))
|
||||
|
||||
|
||||
async def add_payment(
|
||||
session: AsyncSession,
|
||||
tg_id: int,
|
||||
@@ -142,6 +180,21 @@ async def update_payment_status(
|
||||
|
||||
|
||||
async def get_payment_by_payment_id(session: AsyncSession, pid: str) -> dict | None:
|
||||
"""Сначала Redis (pending), затем БД. Из кэша возвращается запись без id — вебхук делает add_payment."""
|
||||
cached = await cache_get(_payment_cache_key(pid))
|
||||
if cached is not None:
|
||||
return {
|
||||
"id": None,
|
||||
"tg_id": cached["tg_id"],
|
||||
"amount": cached["amount"],
|
||||
"currency": cached.get("currency", "RUB"),
|
||||
"status": cached.get("status", "pending"),
|
||||
"payment_system": cached["payment_system"],
|
||||
"payment_id": cached["payment_id"],
|
||||
"created_at": None,
|
||||
"metadata": cached.get("metadata"),
|
||||
"original_amount": cached.get("original_amount"),
|
||||
}
|
||||
try:
|
||||
result = await session.execute(select(Payment).where(Payment.payment_id == pid).limit(1))
|
||||
payment = result.scalar_one_or_none()
|
||||
|
||||
@@ -99,6 +99,35 @@ async def get_tariffs(
|
||||
return []
|
||||
|
||||
|
||||
async def get_tariff_names_groups_subgroups_durations(
|
||||
session: AsyncSession, tariff_ids: list[int]
|
||||
) -> tuple[dict[int, str], dict[int, str], dict[int, str | None], dict[int, int]]:
|
||||
"""Один запрос: id, name, group_code, subgroup_title, duration_days → четыре словаря."""
|
||||
if not tariff_ids:
|
||||
return {}, {}, {}, {}
|
||||
|
||||
result = await session.execute(
|
||||
select(
|
||||
Tariff.id,
|
||||
Tariff.name,
|
||||
Tariff.group_code,
|
||||
Tariff.subgroup_title,
|
||||
Tariff.duration_days,
|
||||
).where(Tariff.id.in_(tariff_ids))
|
||||
)
|
||||
rows = result.all()
|
||||
names = {}
|
||||
groups = {}
|
||||
subgroups = {}
|
||||
durations = {}
|
||||
for tid, name, group_code, subgroup_title, duration_days in rows:
|
||||
names[tid] = name
|
||||
groups[tid] = group_code
|
||||
subgroups[tid] = subgroup_title
|
||||
durations[tid] = duration_days
|
||||
return names, groups, subgroups, durations
|
||||
|
||||
|
||||
async def get_tariff_by_id(session: AsyncSession, tariff_id: int):
|
||||
key = cache_key("tariff", tariff_id)
|
||||
cached = await cache_get(key)
|
||||
|
||||
+62
-1
@@ -6,6 +6,7 @@ from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.cache_config import (
|
||||
BALANCE_CACHE_TTL_SEC,
|
||||
USER_EXISTS_CACHE_TTL_SEC,
|
||||
USER_SNAPSHOT_CACHE_TTL_SEC,
|
||||
)
|
||||
@@ -76,6 +77,14 @@ async def add_user(
|
||||
raise
|
||||
|
||||
|
||||
async def invalidate_balance_cache(tg_id: int) -> None:
|
||||
await cache_delete(cache_key("balance", tg_id))
|
||||
|
||||
|
||||
async def invalidate_profile_cache(tg_id: int) -> None:
|
||||
await cache_delete(cache_key("profile_data", tg_id))
|
||||
|
||||
|
||||
async def update_balance(session: AsyncSession, tg_id: int, amount: float) -> None:
|
||||
try:
|
||||
res = await session.execute(
|
||||
@@ -91,6 +100,8 @@ async def update_balance(session: AsyncSession, tg_id: int, amount: float) -> No
|
||||
logger.info(f"[DB] Баланс пользователя {tg_id} обновлён: {old_balance} → {new_balance}")
|
||||
else:
|
||||
logger.info(f"[DB] Баланс пользователя {tg_id} не изменён: пользователь не найден")
|
||||
await invalidate_balance_cache(tg_id)
|
||||
await invalidate_profile_cache(tg_id)
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[DB] Ошибка при обновлении баланса пользователя {tg_id}: {e}")
|
||||
await session.rollback()
|
||||
@@ -108,15 +119,25 @@ async def check_user_exists(session: AsyncSession, tg_id: int) -> bool:
|
||||
|
||||
|
||||
async def get_balance(session: AsyncSession, tg_id: int) -> float:
|
||||
cached = await cache_get(cache_key("balance", tg_id))
|
||||
if cached is not None:
|
||||
try:
|
||||
return round(float(cached), 1)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
result = await session.execute(select(func.coalesce(User.balance, 0.0)).where(User.tg_id == tg_id))
|
||||
balance = result.scalar_one_or_none()
|
||||
return round(float(balance or 0.0), 1)
|
||||
value = round(float(balance or 0.0), 1)
|
||||
await cache_set(cache_key("balance", tg_id), value, BALANCE_CACHE_TTL_SEC)
|
||||
return value
|
||||
|
||||
|
||||
async def set_user_balance(session: AsyncSession, tg_id: int, balance: float) -> None:
|
||||
try:
|
||||
await session.execute(update(User).where(User.tg_id == tg_id).values(balance=balance))
|
||||
await session.commit()
|
||||
await invalidate_balance_cache(tg_id)
|
||||
await invalidate_profile_cache(tg_id)
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Ошибка при установке баланса для пользователя {tg_id}: {e}")
|
||||
await session.rollback()
|
||||
@@ -127,6 +148,7 @@ async def update_trial(session: AsyncSession, tg_id: int, status: int):
|
||||
try:
|
||||
await session.execute(update(User).where(User.tg_id == tg_id).values(trial=status))
|
||||
await session.commit()
|
||||
await invalidate_profile_cache(tg_id)
|
||||
invalidate_user_snapshot(tg_id)
|
||||
logger.info(f"[DB] Триал статус обновлён для пользователя {tg_id}: {status}")
|
||||
except SQLAlchemyError as e:
|
||||
@@ -141,6 +163,45 @@ async def get_trial(session: AsyncSession, tg_id: int) -> int:
|
||||
return int(trial or 0)
|
||||
|
||||
|
||||
async def get_balance_and_trial(session: AsyncSession, tg_id: int) -> tuple[float, int]:
|
||||
"""Один запрос к БД для баланса и триала (профиль при промахе кэша)."""
|
||||
result = await session.execute(
|
||||
select(
|
||||
func.coalesce(User.balance, 0.0),
|
||||
func.coalesce(User.trial, 0),
|
||||
).where(User.tg_id == tg_id)
|
||||
)
|
||||
row = result.one_or_none()
|
||||
if row is None:
|
||||
return 0.0, 0
|
||||
balance, trial = row
|
||||
return round(float(balance or 0.0), 1), int(trial or 0)
|
||||
|
||||
|
||||
async def get_balance_trial_key_count(session: AsyncSession, tg_id: int) -> tuple[float, int, int]:
|
||||
"""
|
||||
Один запрос: баланс, триал и число ключей пользователя (для профиля при промахе кэша).
|
||||
Возвращает (balance_rub, trial_status, key_count).
|
||||
"""
|
||||
key_count_subq = select(func.count()).select_from(Key).where(Key.tg_id == User.tg_id).scalar_subquery()
|
||||
result = await session.execute(
|
||||
select(
|
||||
func.coalesce(User.balance, 0.0),
|
||||
func.coalesce(User.trial, 0),
|
||||
key_count_subq,
|
||||
).where(User.tg_id == tg_id)
|
||||
)
|
||||
row = result.one_or_none()
|
||||
if row is None:
|
||||
return 0.0, 0, 0
|
||||
balance, trial, key_count = row
|
||||
return (
|
||||
round(float(balance or 0.0), 1),
|
||||
int(trial or 0),
|
||||
int(key_count or 0),
|
||||
)
|
||||
|
||||
|
||||
async def upsert_user(
|
||||
session: AsyncSession,
|
||||
tg_id: int,
|
||||
|
||||
Reference in New Issue
Block a user