audit of client actions/ protection of admin TG v2/ fix of cash accounting status/ update of dependencies

This commit is contained in:
Vladless
2026-03-18 01:17:44 +03:00
parent 77f927660b
commit 6d341a9f57
30 changed files with 2219 additions and 36 deletions
+2
View File
@@ -59,5 +59,7 @@ setup.py
.github/workflows/
modules/
storage/
static/web_uploads/
/web-app/
.license_state
+12 -1
View File
@@ -2,10 +2,11 @@ import hashlib
from collections.abc import AsyncGenerator
from fastapi import Depends, HTTPException, Header, Query
from fastapi import Depends, HTTPException, Header, Query, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from audit import set_api_actor
from database import async_session_maker, identities as idb
from database.models import Admin
@@ -27,6 +28,7 @@ def hash_token(token: str) -> str:
async def verify_admin_token(
admin_id: int = Query(..., alias="tg_id"),
token: str = Header(..., alias="X-Token"),
request: Request = None,
session: AsyncSession = Depends(get_session),
) -> Admin:
hashed = hash_token(token)
@@ -34,24 +36,28 @@ async def verify_admin_token(
admin = result.scalar_one_or_none()
if not admin:
raise HTTPException(status_code=401, detail="Unauthorized")
set_api_actor(request, tg_id=admin.tg_id)
return admin
async def verify_identity_token(
x_identity_id: str = Header(..., alias="X-Identity-Id"),
token: str = Header(..., alias="X-Token"),
request: Request = None,
session: AsyncSession = Depends(get_session),
):
"""Проверяет пару identity_id + token; возвращает Identity. Для использования в API v2."""
identity = await idb.verify_identity_token(session, x_identity_id, token)
if not identity:
raise HTTPException(status_code=401, detail="Unauthorized")
set_api_actor(request, identity_id=identity.id, tg_id=identity.tg_id)
return identity
async def verify_identity_admin(
x_identity_id: str = Header(..., alias="X-Identity-Id"),
token: str = Header(..., alias="X-Token"),
request: Request = None,
session: AsyncSession = Depends(get_session),
):
"""Проверяет identity + token и что identity.is_admin; для админских ручек v2."""
@@ -60,12 +66,14 @@ async def verify_identity_admin(
raise HTTPException(status_code=401, detail="Unauthorized")
if not identity.is_admin:
raise HTTPException(status_code=403, detail="Forbidden")
set_api_actor(request, identity_id=identity.id, tg_id=identity.tg_id)
return identity
async def verify_identity_admin_short(
x_identity_id: str = Header(..., alias="X-Identity-Id"),
token: str = Header(..., alias="X-Token"),
request: Request = None,
):
"""Проверка админа с короткой сессией (для broadcast и др.), чтобы не держать соединение с БД."""
async with async_session_maker() as session:
@@ -75,12 +83,14 @@ async def verify_identity_admin_short(
raise HTTPException(status_code=401, detail="Unauthorized")
if not identity.is_admin:
raise HTTPException(status_code=403, detail="Forbidden")
set_api_actor(request, identity_id=identity.id, tg_id=identity.tg_id)
return identity
async def verify_admin_token_short(
admin_id: int = Query(..., alias="tg_id"),
token: str = Header(..., alias="X-Token"),
request: Request = None,
) -> Admin:
"""Проверка админа с короткой сессией (для broadcast и др.), чтобы не держать соединение с БД."""
hashed = hash_token(token)
@@ -90,4 +100,5 @@ async def verify_admin_token_short(
await session.commit()
if not admin:
raise HTTPException(status_code=401, detail="Unauthorized")
set_api_actor(request, tg_id=admin.tg_id)
return admin
+62 -9
View File
@@ -1,8 +1,14 @@
import asyncio
import os
from time import perf_counter
from fastapi import FastAPI, Request
from starlette.middleware.cors import CORSMiddleware
from starlette.staticfiles import StaticFiles
from config import API_LOGGING, API_VERSION
from audit import ensure_api_context, log_api_access, record_api_access_event_background
from config import API_LOGGING, API_VERSION, API_CORS_ORIGINS
from database import async_session_maker
from logger import logger
if API_VERSION == 1:
@@ -19,22 +25,69 @@ app = FastAPI(
openapi_url="/api/openapi.json",
)
app.add_middleware(
CORSMiddleware,
allow_origins=API_CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.middleware("http")
async def api_access_log_middleware(request: Request, call_next):
context = ensure_api_context(request)
if not API_LOGGING:
return await call_next(request)
response = await call_next(request)
response.headers["X-Request-Id"] = context.request_id
return response
started = perf_counter()
response = await call_next(request)
duration_ms = int((perf_counter() - started) * 1000)
client_ip = request.client.host if request.client else "-"
path_qs = request.url.path
if request.url.query:
path_qs = f"{path_qs}?{request.url.query}"
try:
response = await call_next(request)
except Exception as exc:
duration_ms = int((perf_counter() - started) * 1000)
log_api_access(
request,
status_code=500,
duration_ms=duration_ms,
result="fail",
reason=type(exc).__name__,
)
asyncio.create_task(
record_api_access_event_background(
async_session_maker,
request,
result="fail",
reason=type(exc).__name__,
status_code=500,
)
)
raise
logger.info(f'[API] {client_ip} "{request.method} {path_qs}" {response.status_code} {duration_ms}ms')
duration_ms = int((perf_counter() - started) * 1000)
response.headers["X-Request-Id"] = context.request_id
result = "success" if response.status_code < 400 else "fail"
log_api_access(
request,
status_code=response.status_code,
duration_ms=duration_ms,
result=result,
)
asyncio.create_task(
record_api_access_event_background(
async_session_maker,
request,
result=result,
reason=None if response.status_code < 400 else str(response.status_code),
status_code=response.status_code,
)
)
return response
app.include_router(api_router)
_web_uploads_dir = "static/web_uploads"
os.makedirs(_web_uploads_dir, exist_ok=True)
app.mount("/api/web/uploads", StaticFiles(directory=_web_uploads_dir), name="web_uploads")
+3
View File
@@ -17,6 +17,7 @@ from api.v2.routes import (
settings,
payment_links,
identities,
web,
)
router = APIRouter()
@@ -27,6 +28,7 @@ router.include_router(users.router, prefix="/api/users", tags=["Users"])
router.include_router(keys.router, prefix="/api/keys", tags=["Keys"])
router.include_router(coupons.router, prefix="/api/coupons", tags=["Coupons"])
router.include_router(servers.router, prefix="/api/servers", tags=["Servers"])
router.include_router(tariffs.public_router, prefix="/api/tariffs", tags=["Tariffs"])
router.include_router(tariffs.router, prefix="/api/tariffs", tags=["Tariffs"])
router.include_router(gifts.router, prefix="/api/gifts", tags=["Gifts"])
router.include_router(referrals.router, prefix="/api/referrals", tags=["Referrals"])
@@ -37,3 +39,4 @@ router.include_router(misc.router, prefix="/api")
router.include_router(modules.router, prefix="/api")
router.include_router(management.router, prefix="/api/management", tags=["Management"])
router.include_router(settings.router, prefix="/api/settings", tags=["Settings"])
router.include_router(web.router, prefix="", tags=["Web"])
+76 -3
View File
@@ -1,15 +1,18 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession
from audit import set_api_actor
from api.depends import get_session, verify_identity_token
from api.v2.schemas.identities import (
IdentityResponse,
LinkTelegramRequest,
LoginByCodeRequest,
LoginRequest,
LoginResponse,
LoginTelegramRequest,
RegisterByEmailRequest,
RegisterResponse,
SendLoginCodeRequest,
)
from config import API_TOKEN_TTL_DAYS, API_TOKEN
from database import identities as idb
@@ -23,6 +26,7 @@ TELEGRAM_LOGIN_MAX_AGE = 86400 # 24 часа
@router.post("/register", response_model=RegisterResponse)
async def register_by_email(
body: RegisterByEmailRequest,
request: Request,
session: AsyncSession = Depends(get_session),
):
(
@@ -39,12 +43,14 @@ async def register_by_email(
if existing:
raise HTTPException(status_code=409, detail="Идентичность с таким email уже существует")
identity, token = await idb.create_identity_with_token(session, email=email, password=body.password)
set_api_actor(request, identity_id=identity.id, tg_id=identity.tg_id)
return RegisterResponse(identity_id=identity.id, token=token)
@router.post("/login", response_model=LoginResponse)
async def login(
body: LoginRequest,
request: Request,
session: AsyncSession = Depends(get_session),
):
"""Вход по email и паролю. Возвращает identity_id и новый токен. Срок действия токена: """ + TOKEN_TTL_HINT + "."
@@ -55,12 +61,73 @@ async def login(
if not result:
raise HTTPException(status_code=401, detail="Неверный email или пароль")
identity, token = result
set_api_actor(request, identity_id=identity.id, tg_id=identity.tg_id)
return LoginResponse(identity_id=identity.id, token=token)
_LOGIN_CODES: dict[str, tuple[str, float]] = {}
_LOGIN_CODE_TTL = 600.0 # 10 min
def _clean_login_codes() -> None:
import time
now = time.time()
for k in list(_LOGIN_CODES):
if now - _LOGIN_CODES[k][1] > _LOGIN_CODE_TTL:
del _LOGIN_CODES[k]
@router.post("/send-login-code")
async def send_login_code(
body: SendLoginCodeRequest,
request: Request,
session: AsyncSession = Depends(get_session),
):
"""Отправить код входа на email. Код хранится на сервере 10 мин (для демо — без реальной отправки письма)."""
_clean_login_codes()
email = body.email.strip().lower()
if not email:
raise HTTPException(status_code=400, detail="Email обязателен")
identity = await idb.get_identity_by_email(session, email)
if not identity:
raise HTTPException(status_code=404, detail="Аккаунт с таким email не найден")
import secrets
import time
code = "".join(secrets.choice("0123456789") for _ in range(6))
_LOGIN_CODES[email] = (code, time.time())
return {"ok": True, "message": "Код отправлен на почту"}
@router.post("/login-by-code", response_model=LoginResponse)
async def login_by_code(
body: LoginByCodeRequest,
request: Request,
session: AsyncSession = Depends(get_session),
):
"""Вход по email и коду из письма."""
_clean_login_codes()
email = body.email.strip().lower()
if not email or not body.code or not body.code.strip():
raise HTTPException(status_code=400, detail="Email и код обязательны")
stored = _LOGIN_CODES.get(email)
if not stored:
raise HTTPException(status_code=400, detail="Код не найден или истёк. Запросите новый.")
code_value, _ = stored
if body.code.strip() != code_value:
raise HTTPException(status_code=401, detail="Неверный код")
del _LOGIN_CODES[email]
identity = await idb.get_identity_by_email(session, email)
if not identity:
raise HTTPException(status_code=401, detail="Аккаунт не найден")
token = await idb.issue_token_for_identity(session, identity)
set_api_actor(request, identity_id=identity.id, tg_id=identity.tg_id)
return LoginResponse(identity_id=identity.id, token=token)
@router.post("/login-telegram", response_model=LoginResponse)
async def login_telegram(
body: LoginTelegramRequest,
request: Request,
session: AsyncSession = Depends(get_session),
):
(
@@ -73,22 +140,28 @@ async def login_telegram(
raise HTTPException(status_code=401, detail="Неверная подпись или устаревшие данные от Telegram")
identity = await idb.get_or_create_identity_for_tg(session, body.id)
token = await idb.issue_token_for_identity(session, identity)
set_api_actor(request, identity_id=identity.id, tg_id=identity.tg_id)
return LoginResponse(identity_id=identity.id, token=token)
@router.post("/link-telegram", response_model=IdentityResponse)
async def link_telegram(
body: LinkTelegramRequest,
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
"""Привязывает Telegram (tg_id) к текущей идентичности. Требуется X-Identity-Id и X-Token."""
result = await idb.attach_telegram(session, identity.id, body.tg_id)
"""Привязывает Telegram к текущей идентичности. Требуется подпись от Telegram Login Widget (доказательство владения аккаунтом)."""
payload = body.model_dump(mode="json")
if not verify_telegram_login(payload, API_TOKEN, max_age_seconds=TELEGRAM_LOGIN_MAX_AGE):
raise HTTPException(status_code=401, detail="Неверная подпись или устаревшие данные от Telegram")
result = await idb.attach_telegram(session, identity.id, body.id)
if not result:
raise HTTPException(
status_code=409,
detail="Этот Telegram уже привязан к другой идентичности",
)
set_api_actor(request, identity_id=result.id, tg_id=result.tg_id)
return IdentityResponse.model_validate(result)
+62 -1
View File
@@ -9,12 +9,14 @@ import psutil
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import distinct, exists, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_identity_admin, verify_identity_admin_short
from api.v2.schemas.audit import AuditEventListResponse, AuditEventResponse
from audit import drain_audit_redis_to_db, list_audit_events
from config import API_TOKEN, BOT_SERVICE
from database import async_session_maker, save_blocked_user_ids
from core.bootstrap import MANAGEMENT_CONFIG
@@ -175,6 +177,65 @@ async def get_broadcast_clusters(
return {"clusters": clusters}
@router.get("/audit-events", response_model=AuditEventListResponse)
async def get_audit_events_history(
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
identity_id: str | None = Query(None, description="Фильтр по identity_id"),
tg_id: int | None = Query(None, description="Фильтр по Telegram user id"),
channel: str | None = Query(None, description="api или telegram"),
event_type: str | None = Query(None, description="Точный event_type"),
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
):
"""История аудита клиента по identity_id и/или tg_id."""
if identity_id is None and tg_id is None:
raise HTTPException(status_code=400, detail="Укажите identity_id или tg_id")
events = await list_audit_events(
session,
identity_id=identity_id,
tg_id=tg_id,
channel=channel,
event_type=event_type,
limit=limit,
offset=offset,
)
return AuditEventListResponse(
items=[
AuditEventResponse(
id=getattr(event, "id", None),
event_type=event.event_type,
channel=event.channel,
actor_identity_id=event.actor_identity_id,
actor_tg_id=event.actor_tg_id,
path_or_handler=event.path_or_handler,
entity_type=event.entity_type,
entity_id=event.entity_id,
result=event.result,
reason=event.reason,
metadata=event.metadata_,
request_id=event.request_id,
created_at=event.created_at,
)
for event in events
],
limit=limit,
offset=offset,
)
@router.post("/audit-drain")
async def post_audit_drain(identity=Depends(verify_identity_admin_short)):
"""Выгружает буфер аудита из Redis в БД. Для вызова по крону (например 0 0 * * * в 00:00)."""
try:
count = await drain_audit_redis_to_db(async_session_maker)
return {"success": True, "drained": count}
except Exception as exc:
logger.warning("audit-drain failed: %s", exc)
raise HTTPException(status_code=500, detail=str(exc)) from exc
@router.post("/broadcast")
async def launch_broadcast(
payload: BroadcastLaunchPayload,
+8 -4
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_identity_token
@@ -28,12 +28,16 @@ async def _resolve_tg_id(body: PaymentLinkCreateRequest, session: AsyncSession)
@router.post("/", response_model=PaymentLinkCreateResponse)
async def create_link(
body: PaymentLinkCreateRequest,
http_request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
"""Создаёт платёжную ссылку через выбранную кассу (единая точка входа). Принимает identity_id или tg_id."""
tg_id = await _resolve_tg_id(body, session)
request = PaymentLinkRequest(
try:
tg_id = await _resolve_tg_id(body, session)
except HTTPException:
raise
payment_request = PaymentLinkRequest(
tg_id=tg_id,
amount=body.amount,
currency=body.currency or "RUB",
@@ -42,7 +46,7 @@ async def create_link(
failure_url=body.failure_url,
metadata=body.metadata,
)
result = await create_payment_link(session, request)
result = await create_payment_link(session, payment_request)
return PaymentLinkCreateResponse(
success=result.success,
payment_id=result.payment_id,
+11
View File
@@ -1,5 +1,7 @@
from fastapi import APIRouter
from config import PROJECT_NAME, USERNAME_BOT
router = APIRouter(tags=["Root"])
@@ -11,3 +13,12 @@ async def root():
@router.get("/api/version", include_in_schema=True)
async def version():
return {"version": 2, "api": "v2"}
@router.get("/api/telegram-widget-bot", include_in_schema=True)
async def telegram_widget_bot():
"""Имя бота и имя проекта для веб-клиента."""
return {
"bot_username": USERNAME_BOT.replace("@", ""),
"project_name": (PROJECT_NAME or "Solo").strip() if isinstance(PROJECT_NAME, str) else "Solo",
}
+68 -1
View File
@@ -1,9 +1,76 @@
from fastapi import APIRouter
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session
from api.v2.schemas import TariffBase, TariffResponse, TariffUpdate
from api.v2.schemas.tariffs import TariffGroup, TariffPublic
from api.v2.base_crud import generate_crud_router
from database.models import Tariff
def _tariff_to_public(t: Tariff) -> TariffPublic:
return TariffPublic(
id=t.id,
name=t.name or "",
group_code=t.group_code or "",
duration_days=t.duration_days or 0,
price_rub=t.price_rub or 0,
traffic_limit=t.traffic_limit,
device_limit=t.device_limit,
subgroup_title=t.subgroup_title,
sort_order=t.sort_order,
vless=bool(getattr(t, "vless", False)),
)
public_router = APIRouter()
@public_router.get("/groups", response_model=list[TariffGroup])
async def get_tariff_groups(session: AsyncSession = Depends(get_session)):
"""Публичный список групп тарифов — уникальные значения колонки group_code."""
q = (
select(Tariff.group_code)
.where(Tariff.is_active == True, Tariff.group_code.isnot(None), Tariff.group_code != "")
.distinct()
.order_by(Tariff.group_code)
)
result = await session.execute(q)
values = result.scalars().all()
return [TariffGroup(group_code=v or "") for v in values]
@public_router.get("/public", response_model=list[TariffPublic])
async def get_tariffs_public(
group_code: str | None = Query(None, description="Фильтр по группе тарифов"),
tariff_ids: str | None = Query(None, description="ID тарифов через запятую (приоритет над группой)"),
filter_vless: str | None = Query(
None,
description="vless: только для роутера (vless=True), app: только для приложения (vless=False), иначе все",
),
session: AsyncSession = Depends(get_session),
):
"""Публичный список активных тарифов (без авторизации)."""
q = select(Tariff).where(Tariff.is_active == True).order_by(Tariff.sort_order.asc().nulls_last(), Tariff.price_rub.asc())
if tariff_ids:
try:
ids = [int(x.strip()) for x in tariff_ids.split(",") if x.strip()]
if ids:
q = q.where(Tariff.id.in_(ids))
except ValueError:
pass
elif group_code:
q = q.where(Tariff.group_code == group_code)
if filter_vless == "router":
q = q.where(Tariff.vless == True)
elif filter_vless == "app":
q = q.where(Tariff.vless == False)
result = await session.execute(q)
rows = result.scalars().all()
return [_tariff_to_public(t) for t in rows]
router = generate_crud_router(
model=Tariff,
schema_response=TariffResponse,
+142
View File
@@ -0,0 +1,142 @@
import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from pydantic import BaseModel
from sqlalchemy import select, delete
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_identity_admin
from api.v2.schemas import WebPageResponse, WebPageUpdate, WebBlockResponse, WebTheme
from api.v2.schemas.web import WebUploadResponse
from database.models import WebPage, WebBlock, WebTheme as WebThemeModel
UPLOAD_DIR = Path("static/web_uploads")
ALLOWED_EXTENSIONS = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".mp4", ".webm"})
MAX_FILE_SIZE = 100 * 1024 * 1024
router = APIRouter(tags=["Web"])
class WebPagesListResponse(BaseModel):
slugs: list[str]
KNOWN_PAGE_SLUGS = ["landing", "tariffs", "faq", "login", "dashboard"]
@router.get("/api/web/pages", response_model=WebPagesListResponse)
async def list_web_pages(
session: AsyncSession = Depends(get_session),
):
"""Список slug всех страниц сайта (для экспорта/импорта). Включает известные страницы, даже если запись ещё не создана."""
result = await session.execute(select(WebPage.slug).order_by(WebPage.slug))
from_db = {row[0] for row in result.fetchall()}
slugs = sorted(from_db | set(KNOWN_PAGE_SLUGS))
return WebPagesListResponse(slugs=slugs)
async def get_or_create_page(session: AsyncSession, slug: str) -> WebPage:
result = await session.execute(select(WebPage).where(WebPage.slug == slug))
page = result.scalar_one_or_none()
if page is None:
page = WebPage(slug=slug, title=slug)
session.add(page)
await session.flush()
return page
@router.get("/api/web/pages/{slug}", response_model=WebPageResponse)
async def get_web_page(
slug: str,
session: AsyncSession = Depends(get_session),
):
await get_or_create_page(session, slug)
blocks_result = await session.execute(
select(WebBlock).where(WebBlock.page_slug == slug).order_by(WebBlock.order, WebBlock.id)
)
blocks = [WebBlockResponse.model_validate(b) for b in blocks_result.scalars().all()]
theme_result = await session.execute(select(WebThemeModel).where(WebThemeModel.page_slug == slug))
theme_row = theme_result.scalar_one_or_none()
theme = WebTheme(tokens=theme_row.tokens) if theme_row else None
return WebPageResponse(slug=slug, blocks=blocks, theme=theme)
@router.put("/api/web/pages/{slug}", response_model=WebPageResponse)
async def update_web_page(
slug: str,
body: WebPageUpdate,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_admin),
):
await get_or_create_page(session, slug)
await session.execute(delete(WebBlock).where(WebBlock.page_slug == slug))
for block in body.blocks:
session.add(
WebBlock(
page_slug=slug,
order=block.order,
type=block.type,
data=block.data,
)
)
theme_row = None
if body.theme is not None:
result = await session.execute(select(WebThemeModel).where(WebThemeModel.page_slug == slug))
theme_row = result.scalar_one_or_none()
if theme_row is None:
theme_row = WebThemeModel(page_slug=slug, tokens=body.theme.tokens)
session.add(theme_row)
else:
theme_row.tokens = body.theme.tokens
await session.flush()
blocks_result = await session.execute(
select(WebBlock).where(WebBlock.page_slug == slug).order_by(WebBlock.order, WebBlock.id)
)
blocks = [WebBlockResponse.model_validate(b) for b in blocks_result.scalars().all()]
if theme_row is None:
theme_result = await session.execute(select(WebThemeModel).where(WebThemeModel.page_slug == slug))
theme_row = theme_result.scalar_one_or_none()
theme = WebTheme(tokens=theme_row.tokens) if theme_row else None
return WebPageResponse(slug=slug, blocks=blocks, theme=theme)
@router.post("/api/web/upload", response_model=WebUploadResponse)
async def upload_media(
file: UploadFile = File(...),
identity=Depends(verify_identity_admin),
):
"""Upload image or video for landing blocks and return same-origin URL."""
if not file.filename or "." not in file.filename:
raise HTTPException(400, "Файл должен иметь расширение")
ext = Path(file.filename).suffix.lower()
if ext not in ALLOWED_EXTENSIONS:
raise HTTPException(
400,
f"Разрешены только: {', '.join(sorted(ALLOWED_EXTENSIONS))}",
)
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
size = 0
for chunk in file.file:
size += len(chunk)
if size > MAX_FILE_SIZE:
raise HTTPException(400, f"Размер файла не более {MAX_FILE_SIZE // (1024*1024)} МБ")
await file.seek(0)
name = f"{uuid.uuid4().hex}{ext}"
path = UPLOAD_DIR / name
with open(path, "wb") as f:
while chunk := await file.read(64 * 1024):
f.write(chunk)
url = f"/api/web/uploads/{name}"
return WebUploadResponse(url=url)
+1
View File
@@ -28,3 +28,4 @@ from api.v1.schemas import (
)
from api.v1.schemas.keys import KeyBase, KeyCreateRequest, KeyUpdate
from api.v1.schemas.settings import SettingResponse, SettingUpsert
from api.v2.schemas.web import WebBlockResponse, WebTheme, WebPageResponse, WebPageUpdate
+28
View File
@@ -0,0 +1,28 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from pydantic import BaseModel
class AuditEventResponse(BaseModel):
id: int | None = None
event_type: str
channel: str
actor_identity_id: str | None
actor_tg_id: int | None
path_or_handler: str
entity_type: str | None
entity_id: str | None
result: str
reason: str | None
metadata: dict[str, Any] | None
request_id: str | None
created_at: datetime | None
class AuditEventListResponse(BaseModel):
items: list[AuditEventResponse]
limit: int
offset: int
+18 -1
View File
@@ -40,6 +40,15 @@ class LoginResponse(BaseModel):
token: str
class SendLoginCodeRequest(BaseModel):
email: str = Field(..., min_length=1)
class LoginByCodeRequest(BaseModel):
email: str = Field(..., min_length=1)
code: str = Field(..., min_length=1)
class LoginTelegramRequest(BaseModel):
"""Данные от Telegram Login Widget (кнопка «Войти через Telegram»)."""
@@ -53,7 +62,15 @@ class LoginTelegramRequest(BaseModel):
class LinkTelegramRequest(BaseModel):
tg_id: int = Field(...)
"""Данные от Telegram Login Widget — обязательны для доказательства владения аккаунтом при привязке."""
id: int = Field(..., description="Telegram user id (tg_id)")
first_name: str = Field("")
last_name: str | None = None
username: str | None = None
photo_url: str | None = None
auth_date: int = Field(..., description="Unix timestamp от Telegram")
hash: str = Field(..., description="HMAC подпись для проверки на бэкенде")
class IdentityAttachEmail(BaseModel):
+20
View File
@@ -0,0 +1,20 @@
from pydantic import BaseModel
class TariffGroup(BaseModel):
"""Группа тарифов (group_code) для выбора в лендинге и др."""
group_code: str
class TariffPublic(BaseModel):
"""Публичный список тарифов (без авторизации)."""
id: int
name: str
group_code: str
duration_days: int
price_rub: int
traffic_limit: int | None
device_limit: int | None
subgroup_title: str | None
sort_order: int | None
vless: bool = False
+36
View File
@@ -0,0 +1,36 @@
from typing import Any
from pydantic import BaseModel, Field
class WebBlockBase(BaseModel):
type: str = Field(..., max_length=64)
order: int
data: dict[str, Any]
class WebBlockResponse(WebBlockBase):
id: str
class Config:
from_attributes = True
class WebTheme(BaseModel):
tokens: dict[str, Any]
class WebPageResponse(BaseModel):
slug: str
blocks: list[WebBlockResponse]
theme: WebTheme | None = None
class WebPageUpdate(BaseModel):
blocks: list[WebBlockBase]
theme: WebTheme | None = None
class WebUploadResponse(BaseModel):
url: str
+822
View File
@@ -0,0 +1,822 @@
from __future__ import annotations
import json
import uuid
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from typing import Any, Iterable
from aiogram.types import CallbackQuery, InlineQuery, Message, TelegramObject, User
from fastapi import Request
from sqlalchemy import and_, delete, desc, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import AuditEvent
from logger import logger
try:
from core.cache_config import (
AUDIT_REDIS_BUFFER_ENABLED,
AUDIT_REDIS_DRAIN_BATCH,
AUDIT_REDIS_FLUSH_KEY,
AUDIT_REDIS_IDENTITY_PREFIX,
AUDIT_REDIS_USER_PREFIX,
AUDIT_REDIS_USER_TTL_SEC,
)
except ImportError:
AUDIT_REDIS_BUFFER_ENABLED = False
AUDIT_REDIS_FLUSH_KEY = "audit:flush"
AUDIT_REDIS_USER_PREFIX = "audit:user:tg:"
AUDIT_REDIS_IDENTITY_PREFIX = "audit:user:identity:"
AUDIT_REDIS_USER_TTL_SEC = 25 * 3600
AUDIT_REDIS_DRAIN_BATCH = 1000
_MAX_TEXT_LEN = 160
_AUDIT_TABLE_READY = False
@dataclass
class AuditContext:
request_id: str
channel: str
path_or_handler: str
actor_identity_id: str | None = None
actor_tg_id: int | None = None
def new_request_id() -> str:
return uuid.uuid4().hex
def _trim(value: Any, limit: int = _MAX_TEXT_LEN) -> str | None:
if value is None:
return None
text = str(value).strip()
if not text:
return None
if len(text) <= limit:
return text
return text[: limit - 3] + "..."
def _jsonable(value: Any) -> Any:
if value is None or isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, datetime):
return value.isoformat()
if isinstance(value, dict):
return {str(k): _jsonable(v) for k, v in value.items()}
if isinstance(value, (list, tuple, set)):
return [_jsonable(item) for item in value]
return _trim(value, 500)
def _serialize(payload: dict[str, Any]) -> str:
return json.dumps(_jsonable(payload), ensure_ascii=False, sort_keys=True)
def _message_text(event: TelegramObject) -> str | None:
if isinstance(event, Message):
return _trim(event.text or event.caption)
if isinstance(event, CallbackQuery):
return _trim(event.data)
if isinstance(event, InlineQuery):
return _trim(event.query)
return None
def _event_user(event: TelegramObject) -> User | None:
if hasattr(event, "from_user") and isinstance(event.from_user, User):
return event.from_user
return None
def describe_telegram_event(event: TelegramObject) -> str:
if isinstance(event, Message):
return f"message:{_message_text(event) or '-'}"
if isinstance(event, CallbackQuery):
return f"callback:{_message_text(event) or '-'}"
if isinstance(event, InlineQuery):
return f"inline:{_message_text(event) or '-'}"
return type(event).__name__
def ensure_api_context(request: Request) -> AuditContext:
context = getattr(request.state, "audit_context", None)
if isinstance(context, AuditContext):
return context
path_or_handler = request.url.path
if request.url.query:
path_or_handler = f"{path_or_handler}?{request.url.query}"
context = AuditContext(
request_id=new_request_id(),
channel="api",
path_or_handler=path_or_handler,
)
request.state.audit_context = context
request.state.audit_request_id = context.request_id
return context
def get_api_context(request: Request | None) -> AuditContext | None:
if request is None:
return None
context = getattr(request.state, "audit_context", None)
if isinstance(context, AuditContext):
return context
return None
def set_api_actor(
request: Request,
*,
identity_id: str | None = None,
tg_id: int | None = None,
) -> AuditContext:
context = ensure_api_context(request)
if identity_id is not None:
context.actor_identity_id = identity_id
if tg_id is not None:
context.actor_tg_id = tg_id
return context
def ensure_telegram_context(
data: dict[str, Any] | None,
event: TelegramObject,
) -> AuditContext:
if data is not None:
existing = data.get("audit_context")
if isinstance(existing, AuditContext):
return existing
user = _event_user(event)
context = AuditContext(
request_id=new_request_id(),
channel="telegram",
path_or_handler=describe_telegram_event(event),
actor_tg_id=user.id if user else None,
)
if data is not None:
data["audit_context"] = context
data["audit_request_id"] = context.request_id
return context
def set_telegram_actor(
audit_context: AuditContext | dict[str, Any] | None,
*,
identity_id: str | None = None,
tg_id: int | None = None,
) -> AuditContext | None:
context: AuditContext | None
if isinstance(audit_context, AuditContext):
context = audit_context
elif isinstance(audit_context, dict):
context = audit_context.get("audit_context")
else:
context = None
if not isinstance(context, AuditContext):
return None
if identity_id is not None:
context.actor_identity_id = identity_id
if tg_id is not None:
context.actor_tg_id = tg_id
return context
def get_telegram_context(audit_context: AuditContext | dict[str, Any] | None) -> AuditContext | None:
if isinstance(audit_context, AuditContext):
return audit_context
if isinstance(audit_context, dict):
context = audit_context.get("audit_context")
if isinstance(context, AuditContext):
return context
return None
def log_api_access(
request: Request,
*,
status_code: int,
duration_ms: int,
result: str,
reason: str | None = None,
) -> None:
context = ensure_api_context(request)
client_ip = request.client.host if request.client else "-"
logger.debug(
f"[AUDIT_ACCESS] {_serialize({
'request_id': context.request_id,
'channel': 'api',
'method': request.method,
'path': context.path_or_handler,
'status_code': status_code,
'duration_ms': duration_ms,
'result': result,
'reason': reason,
'client_ip': client_ip,
'actor_identity_id': context.actor_identity_id,
'actor_tg_id': context.actor_tg_id,
})}"
)
def log_telegram_access(
event: TelegramObject,
*,
audit_context: AuditContext | None,
result: str,
reason: str | None = None,
) -> None:
context = audit_context or AuditContext(
request_id=new_request_id(),
channel="telegram",
path_or_handler=describe_telegram_event(event),
)
user = _event_user(event)
logger.debug(
f"[AUDIT_ACCESS] {_serialize({
'request_id': context.request_id,
'channel': 'telegram',
'path_or_handler': context.path_or_handler,
'event_type': type(event).__name__,
'message': _message_text(event),
'result': result,
'reason': reason,
'actor_identity_id': context.actor_identity_id,
'actor_tg_id': context.actor_tg_id or (user.id if user else None),
'username': getattr(user, 'username', None) if user else None,
})}"
)
async def record_audit_event(
session: AsyncSession,
*,
event_type: str,
channel: str,
path_or_handler: str,
actor_identity_id: str | None = None,
actor_tg_id: int | None = None,
entity_type: str | None = None,
entity_id: str | int | None = None,
result: str = "success",
reason: str | None = None,
metadata: dict[str, Any] | None = None,
request_id: str | None = None,
) -> AuditEvent:
await ensure_audit_table(session)
event = AuditEvent(
event_type=event_type,
channel=channel,
actor_identity_id=actor_identity_id,
actor_tg_id=actor_tg_id,
path_or_handler=_trim(path_or_handler, 255) or channel,
entity_type=_trim(entity_type, 64),
entity_id=_trim(entity_id, 255),
result=_trim(result, 32) or "success",
reason=_trim(reason, 1000),
metadata_=_jsonable(metadata) if metadata else None,
request_id=_trim(request_id, 64),
)
session.add(event)
await session.flush()
logger.debug(
f"[AUDIT_EVENT] {_serialize({
'id': event.id,
'request_id': event.request_id,
'channel': event.channel,
'event_type': event.event_type,
'actor_identity_id': event.actor_identity_id,
'actor_tg_id': event.actor_tg_id,
'path_or_handler': event.path_or_handler,
'entity_type': event.entity_type,
'entity_id': event.entity_id,
'result': event.result,
'reason': event.reason,
'metadata': event.metadata_,
})}"
)
return event
async def safe_record_audit_event(session: AsyncSession, **kwargs: Any) -> AuditEvent | None:
try:
return await record_audit_event(session, **kwargs)
except Exception as exc:
logger.warning(f"[Audit] Не удалось записать событие {kwargs.get('event_type')}: {exc}")
return None
def _telegram_access_payload(
audit_context: AuditContext | None,
event: TelegramObject,
*,
result: str = "success",
reason: str | None = None,
) -> dict[str, Any]:
"""Собирает payload для записи telegram_access (для фоновой задачи или синхронной)."""
ctx = get_telegram_context(audit_context)
user = _event_user(event)
path = describe_telegram_event(event)
if ctx is None:
ctx = AuditContext(
request_id=new_request_id(),
channel="telegram",
path_or_handler=path,
actor_tg_id=user.id if user else None,
)
return {
"request_id": ctx.request_id,
"path_or_handler": path,
"actor_identity_id": ctx.actor_identity_id,
"actor_tg_id": ctx.actor_tg_id or (user.id if user else None),
"result": result,
"reason": reason,
}
async def record_telegram_access_event(
session: AsyncSession,
audit_context: AuditContext | None,
event: TelegramObject,
*,
result: str = "success",
reason: str | None = None,
) -> AuditEvent | None:
"""Пишет в БД одно событие «обработчик Telegram вызван» для полного следа пользователя."""
payload = _telegram_access_payload(audit_context, event, result=result, reason=reason)
return await safe_record_audit_event(
session,
event_type="telegram_access",
channel="telegram",
path_or_handler=payload["path_or_handler"],
actor_identity_id=payload["actor_identity_id"],
actor_tg_id=payload["actor_tg_id"],
result=payload["result"],
reason=payload["reason"],
request_id=payload["request_id"],
)
def _audit_record_for_redis(
*,
event_type: str = "telegram_access",
channel: str = "telegram",
path_or_handler: str,
actor_identity_id: str | None = None,
actor_tg_id: int | None = None,
entity_type: str | None = None,
entity_id: str | int | None = None,
result: str = "success",
reason: str | None = None,
request_id: str | None = None,
metadata_: dict | None = None,
) -> dict[str, Any]:
"""Формирует запись для буфера Redis (с created_at в ISO)."""
return {
"event_type": event_type,
"channel": channel,
"path_or_handler": _trim(path_or_handler, 255) or channel,
"actor_identity_id": actor_identity_id,
"actor_tg_id": actor_tg_id,
"entity_type": _trim(entity_type, 64) if entity_type else None,
"entity_id": _trim(str(entity_id), 255) if entity_id is not None else None,
"result": _trim(result, 32) or "success",
"reason": _trim(reason, 1000) if reason else None,
"request_id": _trim(request_id, 64) if request_id else None,
"metadata_": _jsonable(metadata_) if metadata_ else None,
"created_at": datetime.now(timezone.utc).isoformat(),
}
async def record_audit_event_to_redis(
*,
request_id: str | None = None,
path_or_handler: str = "",
actor_identity_id: str | None = None,
actor_tg_id: int | None = None,
result: str = "success",
reason: str | None = None,
) -> None:
"""Пишет событие telegram_access в буфер Redis (списки для выгрузки в БД и для чтения по пользователю)."""
from core.redis_cache import cache_expire, cache_rpush
record = _audit_record_for_redis(
path_or_handler=path_or_handler,
actor_identity_id=actor_identity_id,
actor_tg_id=actor_tg_id,
result=result,
reason=reason,
request_id=request_id,
)
await cache_rpush(AUDIT_REDIS_FLUSH_KEY, record)
if actor_tg_id is not None:
user_key = f"{AUDIT_REDIS_USER_PREFIX}{actor_tg_id}"
await cache_rpush(user_key, record)
await cache_expire(user_key, AUDIT_REDIS_USER_TTL_SEC)
if actor_identity_id:
identity_key = f"{AUDIT_REDIS_IDENTITY_PREFIX}{actor_identity_id}"
await cache_rpush(identity_key, record)
await cache_expire(identity_key, AUDIT_REDIS_USER_TTL_SEC)
async def record_api_access_event_to_redis(
*,
request_id: str | None = None,
path_or_handler: str = "",
actor_identity_id: str | None = None,
actor_tg_id: int | None = None,
result: str = "success",
reason: str | None = None,
) -> None:
"""Пишет событие api_access в буфер Redis (как telegram_access)."""
from core.redis_cache import cache_expire, cache_rpush
record = _audit_record_for_redis(
event_type="api_access",
channel="api",
path_or_handler=path_or_handler,
actor_identity_id=actor_identity_id,
actor_tg_id=actor_tg_id,
result=result,
reason=reason,
request_id=request_id,
)
await cache_rpush(AUDIT_REDIS_FLUSH_KEY, record)
if actor_tg_id is not None:
user_key = f"{AUDIT_REDIS_USER_PREFIX}{actor_tg_id}"
await cache_rpush(user_key, record)
await cache_expire(user_key, AUDIT_REDIS_USER_TTL_SEC)
if actor_identity_id:
identity_key = f"{AUDIT_REDIS_IDENTITY_PREFIX}{actor_identity_id}"
await cache_rpush(identity_key, record)
await cache_expire(identity_key, AUDIT_REDIS_USER_TTL_SEC)
async def record_api_access_event_background(
session_factory: Any,
request: Request,
*,
result: str = "success",
reason: str | None = None,
status_code: int = 200,
) -> None:
"""Пишет одно событие api_access в Redis-буфер или в БД в фоне (после обработки запроса).
Вызывается из middleware; actor берётся из request.state (set_api_actor в эндпоинтах)."""
context = ensure_api_context(request)
path_or_handler = f"{request.method} {request.url.path}"
if request.url.query:
path_or_handler = f"{path_or_handler}?{request.url.query}"
path_or_handler = _trim(path_or_handler, 255) or "api"
if AUDIT_REDIS_BUFFER_ENABLED:
try:
await record_api_access_event_to_redis(
request_id=context.request_id,
path_or_handler=path_or_handler,
actor_identity_id=context.actor_identity_id,
actor_tg_id=context.actor_tg_id,
result=result,
reason=reason,
)
except Exception as exc:
logger.warning("[Audit] Запись api_access в Redis-буфер не удалась: %s", exc)
return
try:
async with session_factory() as session:
await ensure_audit_table(session)
await record_audit_event(
session,
event_type="api_access",
channel="api",
path_or_handler=path_or_handler,
actor_identity_id=context.actor_identity_id,
actor_tg_id=context.actor_tg_id,
result=result,
reason=reason,
request_id=_trim(context.request_id, 64),
)
await session.commit()
except Exception as exc:
logger.warning(
"[Audit] Фоновая запись api_access не удалась: %s",
exc,
extra={"path_or_handler": path_or_handler[:80] if path_or_handler else None},
)
async def record_telegram_access_event_background(
session_factory: Any,
*,
request_id: str | None,
path_or_handler: str,
actor_identity_id: str | None = None,
actor_tg_id: int | None = None,
result: str = "success",
reason: str | None = None,
) -> None:
"""Пишет событие telegram_access: в Redis-буфер (если включён) или в БД в отдельной сессии.
При ошибке логирует и не пробрасывает исключение."""
if AUDIT_REDIS_BUFFER_ENABLED:
try:
await record_audit_event_to_redis(
request_id=request_id,
path_or_handler=path_or_handler,
actor_identity_id=actor_identity_id,
actor_tg_id=actor_tg_id,
result=result,
reason=reason,
)
except Exception as exc:
logger.warning("[Audit] Запись в Redis-буфер не удалась: %s", exc)
return
try:
async with session_factory() as session:
await ensure_audit_table(session)
await record_audit_event(
session,
event_type="telegram_access",
channel="telegram",
path_or_handler=_trim(path_or_handler, 255) or "telegram",
actor_identity_id=actor_identity_id,
actor_tg_id=actor_tg_id,
result=result,
reason=reason,
request_id=_trim(request_id, 64),
)
await session.commit()
except Exception as exc:
logger.warning(
"[Audit] Фоновая запись telegram_access не удалась: %s",
exc,
extra={"path_or_handler": path_or_handler[:80] if path_or_handler else None},
)
async def safe_record_api_event(
session: AsyncSession,
request: Request,
*,
event_type: str,
entity_type: str | None = None,
entity_id: str | int | None = None,
result: str = "success",
reason: str | None = None,
metadata: dict[str, Any] | None = None,
actor_identity_id: str | None = None,
actor_tg_id: int | None = None,
path_or_handler: str | None = None,
) -> AuditEvent | None:
context = ensure_api_context(request)
return await safe_record_audit_event(
session,
event_type=event_type,
channel="api",
path_or_handler=path_or_handler or context.path_or_handler,
actor_identity_id=actor_identity_id if actor_identity_id is not None else context.actor_identity_id,
actor_tg_id=actor_tg_id if actor_tg_id is not None else context.actor_tg_id,
entity_type=entity_type,
entity_id=entity_id,
result=result,
reason=reason,
metadata=metadata,
request_id=context.request_id,
)
async def safe_record_telegram_event(
session: AsyncSession,
audit_context: AuditContext | dict[str, Any] | None,
*,
event_type: str,
entity_type: str | None = None,
entity_id: str | int | None = None,
result: str = "success",
reason: str | None = None,
metadata: dict[str, Any] | None = None,
actor_identity_id: str | None = None,
actor_tg_id: int | None = None,
path_or_handler: str | None = None,
) -> AuditEvent | None:
context = get_telegram_context(audit_context)
return await safe_record_audit_event(
session,
event_type=event_type,
channel="telegram",
path_or_handler=path_or_handler or (context.path_or_handler if context else "telegram"),
actor_identity_id=actor_identity_id if actor_identity_id is not None else (context.actor_identity_id if context else None),
actor_tg_id=actor_tg_id if actor_tg_id is not None else (context.actor_tg_id if context else None),
entity_type=entity_type,
entity_id=entity_id,
result=result,
reason=reason,
metadata=metadata,
request_id=context.request_id if context else None,
)
def _redis_record_to_event_like(rec: dict[str, Any]) -> SimpleNamespace:
"""Превращает запись из Redis в объект с теми же атрибутами, что и AuditEvent."""
created = rec.get("created_at")
if isinstance(created, str):
try:
created = datetime.fromisoformat(created.replace("Z", "+00:00"))
except Exception:
created = datetime.now(timezone.utc)
elif created is None:
created = datetime.now(timezone.utc)
return SimpleNamespace(
id=None,
event_type=rec.get("event_type", "telegram_access"),
channel=rec.get("channel", "telegram"),
path_or_handler=rec.get("path_or_handler") or "",
actor_identity_id=rec.get("actor_identity_id"),
actor_tg_id=rec.get("actor_tg_id"),
entity_type=rec.get("entity_type"),
entity_id=rec.get("entity_id"),
result=rec.get("result", "success"),
reason=rec.get("reason"),
metadata_=rec.get("metadata_"),
request_id=rec.get("request_id"),
created_at=created,
)
async def _list_audit_events_from_redis(
tg_id: int | None,
identity_id: str | None,
channel: str | None,
event_types: list[str] | None,
max_events: int = 3000,
) -> list[SimpleNamespace]:
"""Читает события пользователя из Redis-буфера (для слияния с БД)."""
if not AUDIT_REDIS_BUFFER_ENABLED:
return []
from core.redis_cache import cache_lrange
out: list[SimpleNamespace] = []
seen: set[tuple[str, str]] = set()
keys_to_read = []
if tg_id is not None:
keys_to_read.append(f"{AUDIT_REDIS_USER_PREFIX}{tg_id}")
if identity_id:
keys_to_read.append(f"{AUDIT_REDIS_IDENTITY_PREFIX}{identity_id}")
for key in keys_to_read:
raw = await cache_lrange(key, -max_events, -1)
for rec in reversed(raw):
if not isinstance(rec, dict):
continue
created = rec.get("created_at")
rid = rec.get("request_id") or ""
if (created, rid) in seen:
continue
if channel and rec.get("channel") != channel:
continue
if event_types and rec.get("event_type") not in event_types:
continue
seen.add((str(created), rid))
out.append(_redis_record_to_event_like(rec))
out.sort(key=lambda e: e.created_at, reverse=True)
return out[:max_events]
async def list_audit_events(
session: AsyncSession,
*,
identity_id: str | None = None,
tg_id: int | None = None,
channel: str | None = None,
event_type: str | None = None,
event_types: Iterable[str] | None = None,
limit: int = 100,
offset: int = 0,
) -> list[AuditEvent | SimpleNamespace]:
"""Список событий аудита. При включённом Redis-буфере объединяет данные из БД и Redis."""
await ensure_audit_table(session)
event_types_list = sorted(event_types) if event_types else None
if not AUDIT_REDIS_BUFFER_ENABLED:
stmt = select(AuditEvent)
actor_filters = []
if identity_id:
actor_filters.append(AuditEvent.actor_identity_id == identity_id)
actor_filters.append(and_(AuditEvent.entity_type == "identity", AuditEvent.entity_id == identity_id))
if tg_id is not None:
tg_id_str = str(tg_id)
actor_filters.append(AuditEvent.actor_tg_id == tg_id)
actor_filters.append(and_(AuditEvent.entity_type == "user", AuditEvent.entity_id == tg_id_str))
actor_filters.append(and_(AuditEvent.entity_type == "telegram_user", AuditEvent.entity_id == tg_id_str))
if actor_filters:
stmt = stmt.where(or_(*actor_filters))
if channel:
stmt = stmt.where(AuditEvent.channel == channel)
if event_type:
stmt = stmt.where(AuditEvent.event_type == event_type)
if event_types_list:
stmt = stmt.where(AuditEvent.event_type.in_(event_types_list))
stmt = stmt.order_by(desc(AuditEvent.created_at), desc(AuditEvent.id)).limit(limit).offset(offset)
result = await session.execute(stmt)
return list(result.scalars().all())
redis_events = await _list_audit_events_from_redis(
tg_id, identity_id, channel, event_types_list, max_events=3000
)
need = offset + limit + len(redis_events)
stmt = select(AuditEvent)
actor_filters = []
if identity_id:
actor_filters.append(AuditEvent.actor_identity_id == identity_id)
actor_filters.append(and_(AuditEvent.entity_type == "identity", AuditEvent.entity_id == identity_id))
if tg_id is not None:
tg_id_str = str(tg_id)
actor_filters.append(AuditEvent.actor_tg_id == tg_id)
actor_filters.append(and_(AuditEvent.entity_type == "user", AuditEvent.entity_id == tg_id_str))
actor_filters.append(and_(AuditEvent.entity_type == "telegram_user", AuditEvent.entity_id == tg_id_str))
if actor_filters:
stmt = stmt.where(or_(*actor_filters))
if channel:
stmt = stmt.where(AuditEvent.channel == channel)
if event_type:
stmt = stmt.where(AuditEvent.event_type == event_type)
if event_types_list:
stmt = stmt.where(AuditEvent.event_type.in_(event_types_list))
stmt = stmt.order_by(desc(AuditEvent.created_at), desc(AuditEvent.id)).limit(min(5000, need)).offset(0)
result = await session.execute(stmt)
db_events = list(result.scalars().all())
merged = redis_events + db_events
merged.sort(key=lambda e: (e.created_at, getattr(e, "id", 0)), reverse=True)
return merged[offset : offset + limit]
async def ensure_audit_table(session: AsyncSession) -> None:
global _AUDIT_TABLE_READY
if _AUDIT_TABLE_READY:
return
connection = await session.connection()
await connection.run_sync(AuditEvent.__table__.create, checkfirst=True)
_AUDIT_TABLE_READY = True
async def delete_old_audit_events(
session: AsyncSession,
*,
older_than_days: int = 90,
) -> int:
"""Удаляет события старше N дней. Вызывать по крону/периодике при больших наплывах.
Возвращает количество удалённых строк."""
await ensure_audit_table(session)
threshold = datetime.now(timezone.utc) - timedelta(days=older_than_days)
stmt = delete(AuditEvent).where(AuditEvent.created_at < threshold)
result = await session.execute(stmt)
return result.rowcount or 0
async def drain_audit_redis_to_db(session_factory: Any) -> int:
"""Выгружает буфер аудита из Redis в БД батчами. Вызывать по крону (например в 00:00).
Возвращает количество записанных событий."""
from core.redis_cache import cache_lpop_batch
total = 0
while True:
batch = await cache_lpop_batch(AUDIT_REDIS_FLUSH_KEY, AUDIT_REDIS_DRAIN_BATCH)
if not batch:
break
try:
async with session_factory() as session:
await ensure_audit_table(session)
for rec in batch:
created = rec.get("created_at")
if isinstance(created, str):
try:
created = datetime.fromisoformat(created.replace("Z", "+00:00"))
except Exception:
created = datetime.now(timezone.utc)
elif created is None:
created = datetime.now(timezone.utc)
event = AuditEvent(
event_type=rec.get("event_type", "telegram_access"),
channel=rec.get("channel", "telegram"),
path_or_handler=rec.get("path_or_handler") or "telegram",
actor_identity_id=rec.get("actor_identity_id"),
actor_tg_id=rec.get("actor_tg_id"),
entity_type=rec.get("entity_type"),
entity_id=rec.get("entity_id"),
result=rec.get("result", "success"),
reason=rec.get("reason"),
metadata_=rec.get("metadata_"),
request_id=rec.get("request_id"),
created_at=created,
)
session.add(event)
await session.commit()
total += len(batch)
except Exception as exc:
logger.warning("[Audit] drain_audit_redis_to_db батч не записан: %s", exc)
break
return total
+49
View File
@@ -1,3 +1,42 @@
"""
Сводка по Redis: префиксы ключей и окна жизни (TTL).
Ключ/префикс TTL (сек) Назначение
throttle_counter 1 Счётчик троттлинга (окно сброса)
throttle_notice 1 Показ уведомления «подождите»
concurrency_notice 5 Сообщение «слишком много запросов»
utm_exists 300 UTM-код уже обработан (старт)
user_middleware 60 Снапшот пользователя (debounce*2)
user_snapshot 30 Снапшот пользователя
user_exists 60 Пользователь есть в БД
balance 25 Баланс в профиле
profile_data 25 Данные профиля
key_count 25 Количество ключей
ban_status 60 Статус бана
direct_start_user_exists 20 Пользователь есть (direct start blocker)
admin_access 60 Доступ в админку (да/нет)
remna_server 300 URL сервера Remnawave
remna_profile 20/45 Профиль Remnawave (45 при ошибке)
runtime_configs 86400 Рантайм-конфиг (1 сутки)
sub_response 20 Ответ подписки (subscription)
servers 60 Список серверов
tariff 120 Тариф по ID
tariffs_cluster 120 Тарифы по кластеру
keys_list 25 Список ключей
key_details 45 Детали ключа
key_email 45 email по client_id
payment_pending 3600 Ожидающий платёж (1 ч)
audit_history 300 История действий клиента (админка)
audit:flush Буфер аудита (список для выгрузки в БД в 00:00)
audit:user:tg:* 25 ч События по tg_id для чтения до выгрузки
audit:user:identity:* 25 ч События по identity для чтения до выгрузки
webhook_abuse_fail 60 Счётчик неудачных вебхуков по IP
webhook_abuse_block 300 Блокировка IP по злоупотреблению
При недоступности Redis: повторная попытка подключения через 5 сек
(REDIS_BACKOFF_SEC в redis_cache).
"""
UPDATE_STALE_AGE_SEC = 60
CONCURRENCY_MAX_WAIT_SEC = 300
@@ -62,6 +101,16 @@ PROFILE_DATA_CACHE_TTL_SEC = 25
PAYMENT_PENDING_CACHE_TTL_SEC = 3600
AUDIT_HISTORY_CACHE_TTL_SEC = 300
AUDIT_REDIS_BUFFER_ENABLED = True
AUDIT_REDIS_FLUSH_KEY = "audit:flush"
AUDIT_REDIS_USER_PREFIX = "audit:user:tg:"
AUDIT_REDIS_IDENTITY_PREFIX = "audit:user:identity:"
AUDIT_REDIS_USER_TTL_SEC = 25 * 3600
AUDIT_REDIS_DRAIN_BATCH = 1000
ERROR_THROTTLE_WINDOW_SEC = 60
ERROR_THROTTLE_MAX_KEYS = 500
ERROR_THROTTLE_MESSAGE_MAX_LEN = 120
+65
View File
@@ -137,3 +137,68 @@ async def cache_delete_pattern(pattern: str) -> int:
except Exception:
return deleted
return deleted
async def cache_rpush(key: str, *values: Any) -> int:
"""Добавляет значения в хвост списка. Значения сериализуются в JSON. Возвращает длину списка после или 0 при ошибке."""
if not values:
return 0
client = await _get_redis()
if client is None:
return 0
try:
raw = [json.dumps(v, ensure_ascii=False) for v in values]
return int(await client.rpush(key, *raw))
except Exception:
return 0
async def cache_expire(key: str, ttl_sec: int) -> bool:
"""Устанавливает TTL для ключа. Возвращает True при успехе."""
client = await _get_redis()
if client is None:
return False
try:
return await client.expire(key, max(1, int(ttl_sec)))
except Exception:
return False
async def cache_lrange(key: str, start: int, end: int) -> list[Any]:
"""Возвращает срез списка. end=-1 — до конца. Элементы десериализуются из JSON."""
client = await _get_redis()
if client is None:
return []
try:
raw_list = await client.lrange(key, start, end)
out = []
for raw in raw_list:
try:
out.append(json.loads(raw))
except Exception:
pass
return out
except Exception:
return []
async def cache_lpop_batch(key: str, count: int) -> list[Any]:
"""Забирает до count элементов с головы списка (FIFO). Совместимо с Redis < 6.2."""
if count <= 0:
return []
client = await _get_redis()
if client is None:
return []
out = []
try:
for _ in range(count):
raw = await client.lpop(key)
if raw is None:
break
try:
out.append(json.loads(raw))
except Exception:
pass
return out
except Exception:
return out
+53
View File
@@ -11,6 +11,7 @@ from sqlalchemy import (
DateTime,
Float,
ForeignKey,
Index,
Integer,
Numeric,
String,
@@ -301,6 +302,34 @@ class TrackingSource(DictLikeMixin, Base):
created_at = Column(DateTime, default=datetime.utcnow)
class AuditEvent(DictLikeMixin, Base):
"""События аудита (флоу пользователя)."""
__tablename__ = "audit_events"
__table_args__ = (
Index("ix_audit_events_tg_created", "actor_tg_id", "created_at"),
Index("ix_audit_events_identity_created", "actor_identity_id", "created_at"),
)
id = Column(Integer, primary_key=True, autoincrement=True)
event_type = Column(String(64), nullable=False, index=True)
channel = Column(String(32), nullable=False, index=True)
actor_identity_id = Column(
String(36),
ForeignKey("identities.id", ondelete="SET NULL", onupdate="CASCADE"),
nullable=True,
index=True,
)
actor_tg_id = Column(BigInteger, nullable=True, index=True)
path_or_handler = Column(String(255), nullable=False)
entity_type = Column(String(64), nullable=True, index=True)
entity_id = Column(String(255), nullable=True, index=True)
result = Column(String(32), nullable=False, server_default=text("'success'"))
reason = Column(Text, nullable=True)
metadata_ = Column("metadata", JSONB, nullable=True)
request_id = Column(String(64), nullable=True, index=True)
created_at = Column(DateTime, default=datetime.utcnow, index=True)
class Admin(Base):
__tablename__ = "admins"
@@ -323,3 +352,27 @@ class Setting(DictLikeMixin, Base):
description = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class WebPage(DictLikeMixin, Base):
__tablename__ = "web_pages"
slug = Column(String(64), primary_key=True)
title = Column(String(255), nullable=True)
class WebTheme(DictLikeMixin, Base):
__tablename__ = "web_themes"
page_slug = Column(String(64), ForeignKey("web_pages.slug", ondelete="CASCADE"), primary_key=True)
tokens = Column(JSONB, nullable=False, default=dict)
class WebBlock(DictLikeMixin, Base):
__tablename__ = "web_blocks"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
page_slug = Column(String(64), ForeignKey("web_pages.slug", ondelete="CASCADE"), index=True, nullable=False)
order = Column(Integer, nullable=False, default=0)
type = Column(String(64), nullable=False)
data = Column(JSONB, nullable=False, default=dict)
+2 -1
View File
@@ -1,10 +1,11 @@
from aiogram import Router
from . import users_balance, users_bans, users_gifts, users_hwid, users_keys, users_manage, users_tariffs
from . import users_audit, users_balance, users_bans, users_gifts, users_hwid, users_keys, users_manage, users_tariffs
router = Router()
router.include_router(users_manage.router)
router.include_router(users_audit.router)
router.include_router(users_balance.router)
router.include_router(users_hwid.router)
router.include_router(users_keys.router)
+96 -2
View File
@@ -33,7 +33,12 @@ class AdminUserKeyEditorCallback(CallbackData, prefix="admin_users_key"):
edit: bool = False
async def build_user_edit_kb(tg_id: int, key_records: list, is_banned: bool = False) -> InlineKeyboardMarkup:
async def build_user_edit_kb(
tg_id: int,
key_records: list,
is_banned: bool = False,
admin_role: str | None = None,
) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
current_time = datetime.now(tz=timezone.utc)
@@ -77,6 +82,13 @@ async def build_user_edit_kb(tg_id: int, key_records: list, is_banned: bool = Fa
),
)
builder.row(
InlineKeyboardButton(
text="🕘 История действий",
callback_data=AdminUserEditorCallback(action="users_audit", tg_id=tg_id, data="all|all|0").pack(),
)
)
builder.row(
InlineKeyboardButton(
text="♻️ Восстановить триал",
@@ -97,7 +109,7 @@ async def build_user_edit_kb(tg_id: int, key_records: list, is_banned: bool = Fa
),
)
hook_buttons = await run_hooks("admin_user_edit", tg_id=tg_id, is_banned=is_banned)
hook_buttons = await run_hooks("admin_user_edit", tg_id=tg_id, is_banned=is_banned, admin_role=admin_role)
builder = insert_hook_buttons(builder, hook_buttons)
builder.row(build_editor_btn("🔄 Обновить данные", tg_id, edit=True))
@@ -516,6 +528,88 @@ def build_user_gifts_kb(tg_id: int, gifts: list, page: int = 0) -> InlineKeyboar
return builder.as_markup()
def build_user_audit_kb(
tg_id: int,
channel_filter: str = "all",
category_filter: str = "all",
page: int = 0,
has_prev: bool = False,
has_next: bool = False,
) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="Все",
callback_data=AdminUserEditorCallback(action="users_audit", tg_id=tg_id, data=f"all|{category_filter}|0").pack(),
),
InlineKeyboardButton(
text="API",
callback_data=AdminUserEditorCallback(action="users_audit", tg_id=tg_id, data=f"api|{category_filter}|0").pack(),
),
InlineKeyboardButton(
text="Telegram",
callback_data=AdminUserEditorCallback(action="users_audit", tg_id=tg_id, data=f"telegram|{category_filter}|0").pack(),
),
)
category_labels = {
"all": "Все",
"auth": "Auth",
"payments": "Платежи",
"subscriptions": "Подписки",
"marketing": "Маркетинг",
}
category_row = [
InlineKeyboardButton(
text=category_labels[category_key],
callback_data=AdminUserEditorCallback(
action="users_audit",
tg_id=tg_id,
data=f"{channel_filter}|{category_key}|0",
).pack(),
)
for category_key in ("all", "auth", "payments", "subscriptions", "marketing")
]
builder.row(*category_row[:3])
builder.row(*category_row[3:])
if has_prev or has_next:
nav_buttons = []
if has_prev:
nav_buttons.append(
InlineKeyboardButton(
text="<",
callback_data=AdminUserEditorCallback(
action="users_audit_page",
tg_id=tg_id,
data=f"{channel_filter}|{category_filter}|{page - 1}",
).pack(),
)
)
nav_buttons.append(InlineKeyboardButton(text=str(page + 1), callback_data="noop"))
if has_next:
nav_buttons.append(
InlineKeyboardButton(
text=">",
callback_data=AdminUserEditorCallback(
action="users_audit_page",
tg_id=tg_id,
data=f"{channel_filter}|{category_filter}|{page + 1}",
).pack(),
)
)
builder.row(*nav_buttons)
builder.row(
InlineKeyboardButton(
text="Назад",
callback_data=AdminUserEditorCallback(action="users_editor", tg_id=tg_id, edit=True).pack(),
)
)
return builder.as_markup()
def build_gift_delete_confirm_kb(tg_id: int, gift_id: str, page: int = 0) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
+490
View File
@@ -0,0 +1,490 @@
import html
from datetime import datetime
from types import SimpleNamespace
import pytz
from aiogram import F, Router
from aiogram.types import CallbackQuery, Message
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from audit import list_audit_events
from core.cache_config import AUDIT_HISTORY_CACHE_TTL_SEC
from core.redis_cache import cache_get, cache_key, cache_set
from database.models import User
from filters.admin import IsAdminFilter
from .keyboard import AdminUserEditorCallback, build_user_audit_kb
MOSCOW_TZ = pytz.timezone("Europe/Moscow")
PAGE_SIZE = 10
router = Router()
def _serialize_audit_events(events: list) -> list[dict]:
"""Для кэша Redis: список событий в JSON-сериализуемый вид."""
out = []
for e in events:
out.append({
"event_type": e.event_type,
"channel": e.channel,
"path_or_handler": getattr(e, "path_or_handler", None) or "",
"actor_identity_id": getattr(e, "actor_identity_id", None),
"actor_tg_id": getattr(e, "actor_tg_id", None),
"entity_type": getattr(e, "entity_type", None),
"entity_id": getattr(e, "entity_id", None),
"result": getattr(e, "result", "success"),
"reason": getattr(e, "reason", None),
"metadata_": getattr(e, "metadata_", None),
"request_id": getattr(e, "request_id", None),
"created_at": e.created_at.isoformat() if e.created_at else None,
})
return out
def _deserialize_audit_events(cached: list[dict]) -> list:
"""Из кэша: список dict → объекты с атрибутами как у AuditEvent."""
out = []
for d in cached:
created = d.get("created_at")
if isinstance(created, str):
try:
created = datetime.fromisoformat(created.replace("Z", "+00:00"))
except Exception:
created = None
out.append(SimpleNamespace(
event_type=d.get("event_type", ""),
channel=d.get("channel", "telegram"),
path_or_handler=d.get("path_or_handler") or "",
actor_identity_id=d.get("actor_identity_id"),
actor_tg_id=d.get("actor_tg_id"),
entity_type=d.get("entity_type"),
entity_id=d.get("entity_id"),
result=d.get("result", "success"),
reason=d.get("reason"),
metadata_=d.get("metadata_"),
request_id=d.get("request_id"),
created_at=created,
))
return out
EVENT_CATEGORY_MAP = {
"auth": {
"register_success",
"register_failed",
"login_success",
"login_failed",
"login_code_sent",
"login_code_send_failed",
"login_by_code_success",
"login_by_code_failed",
"telegram_login_success",
"telegram_login_failed",
"telegram_link_success",
"telegram_link_failed",
},
"payments": {
"payment_menu_opened",
"payment_currency_selected",
"balance_screen_opened",
"balance_history_opened",
"payment_link_created",
"payment_link_create_failed",
"coupon_activation_success",
"coupon_activation_failed",
"coupon_renewal_success",
"coupon_renewal_failed",
"coupon_key_selection_opened",
},
"subscriptions": {
"trial_requested",
"key_purchase_started",
"keys_list_opened",
"key_view_opened",
"key_alias_updated",
"key_hwid_reset",
"key_renew_requested",
"key_renew_blocked",
"key_renew_insufficient_funds",
"key_renew_completed",
"key_renew_failed",
},
"marketing": {
"start_link_opened",
"coupon_link_opened",
"gift_link_opened",
"referral_link_opened",
"referral_link_applied",
"referral_link_failed",
"referral_screen_opened",
"referral_qr_opened",
"top_referrals_opened",
"utm_link_opened",
"utm_link_failed",
},
}
EVENT_TYPE_LABELS = {
"start_entry_opened": "Старт бота",
"start_link_opened": "Переход по ссылке",
"register_success": "Регистрация (успех)",
"register_failed": "Регистрация (ошибка)",
"login_success": "Вход по паролю",
"login_failed": "Вход (ошибка)",
"login_code_sent": "Код входа отправлен",
"login_code_send_failed": "Код входа не отправлен",
"login_by_code_success": "Вход по коду",
"login_by_code_failed": "Вход по коду (ошибка)",
"telegram_login_success": "Вход через Telegram",
"telegram_login_failed": "Вход через Telegram (ошибка)",
"telegram_link_success": "Привязка Telegram",
"telegram_link_failed": "Привязка Telegram (ошибка)",
"payment_menu_opened": "Меню оплаты",
"payment_currency_selected": "Выбор валюты",
"balance_screen_opened": "Экран баланса",
"balance_history_opened": "История пополнений",
"payment_link_created": "Создана платёжная ссылка",
"payment_link_create_failed": "Ошибка создания ссылки",
"coupon_activation_success": "Купон активирован",
"coupon_activation_failed": "Купон не активирован",
"coupon_renewal_success": "Продление по купону",
"coupon_renewal_failed": "Продление по купону (ошибка)",
"coupon_key_selection_opened": "Выбор ключа для купона",
"coupon_link_opened": "Переход по ссылке купона",
"trial_requested": "Запрос триала",
"key_purchase_started": "Начало покупки подписки",
"keys_list_opened": "Список подписок",
"key_view_opened": "Карточка подписки",
"key_alias_updated": "Переименование подписки",
"key_hwid_reset": "Сброс устройств (HWID)",
"key_renew_requested": "Запрос продления",
"key_renew_blocked": "Продление ещё недоступно",
"key_renew_insufficient_funds": "Не хватило баланса на продление",
"key_renew_completed": "Подписка продлена",
"key_renew_failed": "Ошибка продления",
"gift_link_opened": "Переход по подарочной ссылке",
"referral_link_opened": "Переход по реферальной ссылке",
"referral_link_applied": "Реферал применён",
"referral_link_failed": "Реферальная ссылка (ошибка)",
"referral_screen_opened": "Экран рефералов",
"referral_qr_opened": "QR реферальной ссылки",
"top_referrals_opened": "Топ рефералов",
"utm_link_opened": "Переход по UTM",
"utm_link_failed": "UTM не найден",
}
def _parse_filter_page(raw_data: str | int | None) -> tuple[str, str, int]:
if isinstance(raw_data, str):
parts = raw_data.split("|")
if len(parts) == 3:
channel_filter, category_filter, page_str = parts
elif len(parts) == 2:
channel_filter, page_str = parts
category_filter = "all"
else:
return "all", "all", 0
if channel_filter not in {"all", "api", "telegram"}:
channel_filter = "all"
if category_filter not in {"all", "auth", "payments", "subscriptions", "marketing"}:
category_filter = "all"
if page_str.isdigit():
return channel_filter, category_filter, int(page_str)
return channel_filter, category_filter, 0
return "all", "all", 0
def _resolve_event_types(category_filter: str) -> list[str] | None:
if category_filter == "all":
return None
category_events = EVENT_CATEGORY_MAP.get(category_filter)
if not category_events:
return None
return sorted(category_events)
# Порядок и подписи блоков при показе «все» категории
CATEGORY_BLOCK_ORDER = ("auth", "subscriptions", "payments", "marketing", "other")
CATEGORY_BLOCK_LABELS = {
"auth": "Авторизация",
"payments": "Платежи",
"subscriptions": "Подписки",
"marketing": "Маркетинг",
"other": "Другое",
}
def _event_category(event) -> str:
"""Определяет категорию события для группировки (при выборке «все»)."""
path = (getattr(event, "path_or_handler", None) or "").lower()
etype = (getattr(event, "event_type", None) or "").lower()
if etype != "telegram_access":
for cat, event_types in EVENT_CATEGORY_MAP.items():
if etype in event_types:
return cat
return "other"
if "handlers.keys" in path or "key_view" in path or "key_create" in path or "key_renew" in path or "tariffs" in path or "key_tariffs" in path or "addon" in path or "key_addons" in path:
return "subscriptions"
if "handlers.payments" in path or "pay" in path or "balance" in path or "handlers.coupons" in path or "payment" in path:
return "payments"
if "handlers.refferal" in path or "referral" in path or "gift" in path or "utm" in path:
return "marketing"
if "handlers.start" in path or "start_entry" in path or "process_start" in path:
return "marketing"
if "auth" in path or "login" in path or "register" in path:
return "auth"
return "other"
def _format_metadata(metadata: dict | None) -> str | None:
if not metadata:
return None
items = []
for key in sorted(metadata.keys()):
value = metadata[key]
if value is None or value == "":
continue
items.append(f"{key}={value}")
if len(items) >= 3:
break
if not items:
return None
return ", ".join(items)
def _event_label(event_type: str) -> str:
return EVENT_TYPE_LABELS.get(event_type, event_type)
# Отступ для строки события под статусом (чтобы не слипалось)
_FLOW_INDENT = " "
def _humanize_path(path: str) -> str:
"""Сокращает типичные callback для админки до читаемого вида."""
if not path or "callback:" not in path:
return path
# callback:admin_users:users_audit:476217106:telegram | all | 0:0
if "users_audit:" in path:
rest = path.split("users_audit:", 1)[-1].strip()
parts = [p.strip() for p in rest.split("|")[:2] if p.strip()]
if len(parts) >= 2:
ch, cat = parts[0].split(":")[-1] if ":" in parts[0] else parts[0], parts[1]
return f"История: {ch}, {cat}"
return "История"
if "users_editor:" in path:
return "Карточка пользователя"
if "admin_panel:search_user" in path:
return "Поиск пользователя"
if "admin_panel:admin" in path:
return "Админ-панель"
if "message:" in path:
text = path.split("message:", 1)[-1].strip()
if text and text != "-":
return f"Сообщение: {text[:40]}{'' if len(text) > 40 else ''}"
return path[:55] + ("" if len(path) > 55 else "")
def _format_event_line(
event, show_request_id: bool = False, inside_block: bool = False, skip_time: bool = False
) -> str:
"""Строка события. Если skip_time=True — только описание (время уже в строке статуса)."""
created_at = event.created_at.replace(tzinfo=pytz.UTC).astimezone(MOSCOW_TZ).strftime("%d.%m %H:%M:%S")
if event.event_type == "telegram_access":
raw_path = (event.path_or_handler or "").strip()
event_name = html.escape(_humanize_path(raw_path))
if event_name == raw_path and len(raw_path) > 55:
event_name = html.escape(raw_path[:55] + "")
else:
event_name = html.escape(_event_label(event.event_type))
req_part = ""
if show_request_id and not inside_block and getattr(event, "request_id", None):
short_id = (event.request_id or "")[:8]
if short_id:
req_part = f" <code>[{short_id}]</code>"
entity = ""
if event.entity_type or event.entity_id:
etype = html.escape(str(event.entity_type or "entity"))
raw_id = str(event.entity_id or "")
eid = html.escape(raw_id[:40] + ("" if len(raw_id) > 40 else ""))
if event.entity_type == "telegram_user" and raw_id.isdigit():
entity = f"\n{_FLOW_INDENT}tg_id: <code>{eid}</code>"
else:
entity = f"\n{_FLOW_INDENT}{etype}: <code>{eid}</code>"
metadata_line = _format_metadata(event.metadata_)
if metadata_line:
metadata_line = f"\n{_FLOW_INDENT}{html.escape(metadata_line)}"
else:
metadata_line = ""
reason_line = ""
if event.reason:
reason_line = f"\n{_FLOW_INDENT}{html.escape(str(event.reason)[:100])}"
time_part = "" if skip_time else f"<code>{created_at}</code> "
return f"{time_part}{event_name}{req_part}{entity}{metadata_line}{reason_line}"
def _format_event_status(event) -> str:
"""Только время и результат (ок/ошибка)."""
created_at = event.created_at.replace(tzinfo=pytz.UTC).astimezone(MOSCOW_TZ).strftime("%d.%m %H:%M:%S")
result_text = "ок" if event.result == "success" else "ошибка"
return f"<code>{created_at}</code> {result_text}"
# Разделитель между событиями — сразу видно границу "что где"
_FLOW_SEP = ""
def _render_events_as_flow(events: list) -> list[str]:
"""Флоу: успешные действия (ок) тянутся через ⤷; если не ок — отдельный блок с │. Между блоками — разделитель."""
if not events:
return []
lines = []
for i, event in enumerate(events):
n = i + 1
is_ok = event.result == "success"
prefix = "" if is_ok else ""
if not is_ok and i > 0:
lines.append(_FLOW_SEP)
status_line = f"{prefix}<b>{n}.</b> {_format_event_status(event)}"
body = _format_event_line(event, show_request_id=False, inside_block=True, skip_time=True)
quoted_body = f"<blockquote>{body}</blockquote>"
lines.append(status_line)
lines.append(quoted_body)
if i < len(events) - 1:
lines.append(_FLOW_SEP)
return lines
async def _render_user_audit(
message: Message,
session: AsyncSession,
tg_id: int,
*,
channel_filter: str = "all",
category_filter: str = "all",
page: int = 0,
) -> None:
page = max(0, page)
user_identity_id = await session.scalar(select(User.identity_id).where(User.tg_id == tg_id))
channel = None if channel_filter == "all" else channel_filter
event_types = _resolve_event_types(category_filter)
cache_key_str = cache_key(
"audit_history",
tg_id,
user_identity_id or "",
channel_filter,
category_filter,
page,
)
cached = await cache_get(cache_key_str)
if cached is not None and isinstance(cached, list):
raw = _deserialize_audit_events(cached)
has_prev = page > 0
has_next = len(raw) > PAGE_SIZE
events = raw[:PAGE_SIZE]
else:
raw = await list_audit_events(
session,
tg_id=tg_id,
identity_id=user_identity_id,
channel=channel,
event_types=event_types,
limit=PAGE_SIZE + 1,
offset=page * PAGE_SIZE,
)
has_prev = page > 0
has_next = len(raw) > PAGE_SIZE
events = raw[:PAGE_SIZE]
if raw:
await cache_set(
cache_key_str,
_serialize_audit_events(raw),
AUDIT_HISTORY_CACHE_TTL_SEC,
)
full_flow = channel_filter == "all" and category_filter == "all"
lines = [f"🕘 <b>История действий клиента</b> <code>{tg_id}</code>"]
if full_flow:
lines.append("📋 <i>Вся хронология. Цифра — номер действия, черта — граница между действиями.</i>")
lines.append(
f"📎 Канал: <b>{html.escape(channel_filter)}</b> | Категория: <b>{html.escape(category_filter)}</b>"
)
if user_identity_id:
lines.append(f"🆔 Identity: <code>{html.escape(user_identity_id)}</code>")
if not events:
lines.append("\n<i>Событий пока нет.</i>")
else:
lines.append("")
rev = list(reversed(events)) # хронология сверху вниз
if full_flow:
by_cat: dict[str, list] = {}
for e in rev:
c = _event_category(e)
by_cat.setdefault(c, []).append(e)
visible_cats = [c for c in CATEGORY_BLOCK_ORDER if c in by_cat]
for idx, cat in enumerate(visible_cats):
lines.append(f"<b>▸ {CATEGORY_BLOCK_LABELS[cat]}</b>")
lines.extend(_render_events_as_flow(by_cat[cat]))
if idx < len(visible_cats) - 1:
lines.append(_FLOW_SEP)
else:
lines.extend(_render_events_as_flow(rev))
await message.edit_text(
text="\n".join(lines).strip(),
reply_markup=build_user_audit_kb(
tg_id=tg_id,
channel_filter=channel_filter,
category_filter=category_filter,
page=page,
has_prev=has_prev,
has_next=has_next,
),
disable_web_page_preview=True,
)
@router.callback_query(
AdminUserEditorCallback.filter(F.action == "users_audit"),
IsAdminFilter(),
)
async def handle_user_audit(
callback_query: CallbackQuery,
callback_data: AdminUserEditorCallback,
session: AsyncSession,
):
channel_filter, category_filter, page = _parse_filter_page(callback_data.data)
await _render_user_audit(
callback_query.message,
session,
callback_data.tg_id,
channel_filter=channel_filter,
category_filter=category_filter,
page=page,
)
@router.callback_query(
AdminUserEditorCallback.filter(F.action == "users_audit_page"),
IsAdminFilter(),
)
async def handle_user_audit_page(
callback_query: CallbackQuery,
callback_data: AdminUserEditorCallback,
session: AsyncSession,
):
channel_filter, category_filter, page = _parse_filter_page(callback_data.data)
await _render_user_audit(
callback_query.message,
session,
callback_data.tg_id,
channel_filter=channel_filter,
category_filter=category_filter,
page=page,
)
+1
View File
@@ -280,4 +280,5 @@ async def handle_balance_input(message: Message, state: FSMContext, session: Asy
status="success",
)
await state.clear()
await message.answer(text=text, reply_markup=build_users_balance_change_kb(tg_id))
+12 -5
View File
@@ -18,7 +18,7 @@ from database import (
get_key_details,
update_trial,
)
from database.models import Key, ManualBan, Payment, Referral, User
from database.models import Admin, Key, ManualBan, Payment, Referral, User
from filters.admin import IsAdminFilter
from handlers.utils import sanitize_key_name
from utils.csv_export import export_referrals_csv
@@ -88,7 +88,7 @@ async def handle_key_name_input(message: Message, state: FSMContext, session: As
)
return
await process_user_search(message, state, session, key_details["tg_id"])
await process_user_search(message, state, session, key_details["tg_id"], actor_tg_id=message.from_user.id)
@router.message(UserEditorState.waiting_for_user_data, IsAdminFilter())
@@ -97,7 +97,7 @@ async def handle_user_data_input(message: Message, state: FSMContext, session: A
if message.forward_from:
tg_id = message.forward_from.id
await process_user_search(message, state, session, tg_id)
await process_user_search(message, state, session, tg_id, actor_tg_id=message.from_user.id)
return
if not message.text:
@@ -124,7 +124,7 @@ async def handle_user_data_input(message: Message, state: FSMContext, session: A
)
return
await process_user_search(message, state, session, tg_id)
await process_user_search(message, state, session, tg_id, actor_tg_id=message.from_user.id)
@router.callback_query(
@@ -336,6 +336,7 @@ async def process_user_search(
session: AsyncSession,
tg_id: int,
edit: bool = False,
actor_tg_id: int | None = None,
) -> None:
await state.clear()
@@ -435,7 +436,12 @@ async def process_user_search(
text = text_builder.as_html()
kb = await build_user_edit_kb(tg_id, key_records, is_banned=is_banned)
effective_actor_tg_id = actor_tg_id or (message.from_user.id if message.from_user else None)
admin_role = None
if effective_actor_tg_id is not None:
admin_role = await session.scalar(select(Admin.role).where(Admin.tg_id == effective_actor_tg_id))
kb = await build_user_edit_kb(tg_id, key_records, is_banned=is_banned, admin_role=admin_role)
if edit:
try:
@@ -462,4 +468,5 @@ async def handle_users_editor(
session=session,
tg_id=callback_data.tg_id,
edit=callback_data.edit,
actor_tg_id=callback.from_user.id,
)
-1
View File
@@ -151,7 +151,6 @@ async def _build_pay_menu_for_currency(currency: str) -> InlineKeyboardBuilder:
@router.callback_query(F.data.startswith("pay_currency|"))
async def handle_pay_currency(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
currency = callback_query.data.split("|")[1]
if currency == "STARS":
return await process_callback_pay_stars(callback_query, state, session)
-1
View File
@@ -190,7 +190,6 @@ async def show_referral_qr(callback_query: CallbackQuery):
reply_markup=builder.as_markup(),
media_path=qr_path,
)
os.remove(qr_path)
except Exception as error:
-2
View File
@@ -26,7 +26,6 @@ from core.cache_config import START_UTM_EXISTS_TTL_SEC
from core.redis_cache import cache_get, cache_key, cache_set
from database import (
add_user,
get_coupon_by_code,
get_user_snapshot,
upsert_source_if_empty,
)
@@ -94,7 +93,6 @@ async def start_entry(
captcha: bool = True,
):
message = event.message if isinstance(event, CallbackQuery) else event
try:
await run_hooks("start_entry", message=message, event=event, state=state, session=session, admin=admin)
except Exception as e:
+1 -2
View File
@@ -60,7 +60,6 @@ def register_middleware(
if PROBE_LOGGING:
dispatcher.update.outer_middleware(StreamProbeMiddleware("global"))
# Первым делом отвечаем на callback, чтобы не уйти в «query is too old» при очереди
dispatcher.update.outer_middleware(EarlyCallbackAnswerMiddleware())
if middleware_enabled("runtime_config_sync"):
@@ -81,7 +80,7 @@ def register_middleware(
available_middlewares = {
"admin": AdminMiddleware(),
"maintenance": MaintenanceModeMiddleware(),
"logging": LoggingMiddleware(),
"logging": LoggingMiddleware(sessionmaker) if sessionmaker else LoggingMiddleware(),
"throttling": ThrottlingMiddleware(),
"user": UserMiddleware(),
"answer": CallbackAnswerMiddleware(),
+78 -2
View File
@@ -5,6 +5,14 @@ from typing import Any, TypedDict
from aiogram import BaseMiddleware
from aiogram.types import CallbackQuery, InlineQuery, Message, TelegramObject, User
from audit import (
ensure_telegram_context,
log_telegram_access,
record_telegram_access_event,
record_telegram_access_event_background,
set_telegram_actor,
_telegram_access_payload,
)
from logger import logger
@@ -25,7 +33,12 @@ def _log_activity_sync(user_info: UserInfo) -> None:
class LoggingMiddleware(BaseMiddleware):
"""Middleware для логирования действий пользователя. Лог пишется в фоне, не задерживая обработчик."""
"""Middleware для логирования действий пользователя. Лог пишется в фоне, не задерживая обработчик.
Если передан sessionmaker аудит пишется в отдельной сессии в фоне (не блокирует ответ)."""
def __init__(self, sessionmaker=None):
super().__init__()
self._sessionmaker = sessionmaker
async def __call__(
self,
@@ -33,12 +46,75 @@ class LoggingMiddleware(BaseMiddleware):
event: TelegramObject,
data: dict[str, Any],
) -> Any:
audit_context = ensure_telegram_context(data, event)
user_info = self._extract_user_info(event)
if user_info["user_id"]:
asyncio.create_task(asyncio.to_thread(_log_activity_sync, user_info))
return await handler(event, data)
try:
result = await handler(event, data)
db_user = data.get("user")
if isinstance(db_user, dict):
set_telegram_actor(
audit_context,
identity_id=db_user.get("identity_id"),
tg_id=db_user.get("tg_id"),
)
if self._sessionmaker is not None:
payload = _telegram_access_payload(audit_context, event, result="success")
asyncio.create_task(
record_telegram_access_event_background(self._sessionmaker, **payload)
)
else:
session = data.get("session")
if session is not None:
await record_telegram_access_event(
session,
audit_context,
event,
result="success",
)
asyncio.create_task(
asyncio.to_thread(
log_telegram_access,
event,
audit_context=audit_context,
result="success",
)
)
return result
except Exception as exc:
reason = type(exc).__name__
if self._sessionmaker is not None:
payload = _telegram_access_payload(
audit_context, event, result="fail", reason=reason
)
asyncio.create_task(
record_telegram_access_event_background(
self._sessionmaker, **payload
)
)
else:
session = data.get("session")
if session is not None:
await record_telegram_access_event(
session,
audit_context,
event,
result="fail",
reason=reason,
)
asyncio.create_task(
asyncio.to_thread(
log_telegram_access,
event,
audit_context=audit_context,
result="fail",
reason=reason,
)
)
raise
def _extract_user_info(self, event: TelegramObject) -> UserInfo:
"""Извлекает информацию о пользователе из различных типов событий."""
+1
View File
@@ -48,6 +48,7 @@ pydantic_core==2.23.4
Pygments==2.19.2
python-dateutil==2.9.0.post0
pytz==2025.1
python-multipart==0.0.22
qrcode==8.2
requests==2.32.4
redis==5.2.0