diff --git a/web/lib/api_logger.py b/web/lib/api_logger.py
new file mode 100644
index 0000000..b5d05ec
--- /dev/null
+++ b/web/lib/api_logger.py
@@ -0,0 +1,82 @@
+"""Thin wrapper around httpx that logs every outbound API call to api_logs."""
+import json
+import time
+import urllib.parse
+from typing import Any
+
+import httpx
+
+from web.database import SessionLocal
+from web.models.connections import ApiLog
+
+_MAX_BODY = 8000 # truncate stored bodies beyond this
+
+
+def _service_from_url(url: str) -> str:
+ host = urllib.parse.urlparse(url).netloc
+ if "evotor" in host:
+ return "evotor"
+ if "vk.com" in host:
+ return "vk"
+ return "other"
+
+
+def _truncate(text: str | None) -> str | None:
+ if text and len(text) > _MAX_BODY:
+ return text[:_MAX_BODY] + "…"
+ return text
+
+
+def _record(
+ user_id: int | None,
+ method: str,
+ url: str,
+ request_body: str | None,
+ response_status: int | None,
+ response_body: str | None,
+ duration_ms: int,
+) -> None:
+ try:
+ db = SessionLocal()
+ db.add(ApiLog(
+ user_id=user_id,
+ service=_service_from_url(url),
+ method=method.upper(),
+ url=url,
+ request_body=_truncate(request_body),
+ response_status=response_status,
+ response_body=_truncate(response_body),
+ duration_ms=duration_ms,
+ ))
+ db.commit()
+ db.close()
+ except Exception:
+ pass # never let logging crash the caller
+
+
+def get(url: str, *, user_id: int | None = None, **kwargs) -> httpx.Response:
+ t0 = time.monotonic()
+ resp = httpx.get(url, **kwargs)
+ ms = int((time.monotonic() - t0) * 1000)
+ try:
+ body = resp.text
+ except Exception:
+ body = None
+ _record(user_id, "GET", url, None, resp.status_code, body, ms)
+ return resp
+
+
+def post(url: str, *, user_id: int | None = None, data: Any = None, json: Any = None, **kwargs) -> httpx.Response:
+ t0 = time.monotonic()
+ resp = httpx.post(url, data=data, json=json, **kwargs)
+ ms = int((time.monotonic() - t0) * 1000)
+ try:
+ req_body = resp.request.content.decode("utf-8", errors="replace") if resp.request.content else None
+ except Exception:
+ req_body = None
+ try:
+ body = resp.text
+ except Exception:
+ body = None
+ _record(user_id, "POST", url, req_body, resp.status_code, body, ms)
+ return resp
diff --git a/web/main.py b/web/main.py
index 998c430..5c02e17 100644
--- a/web/main.py
+++ b/web/main.py
@@ -38,6 +38,7 @@ from web.routes.admin import router as admin_router # noqa: E402
from web.routes.catalog import router as catalog_router # noqa: E402
from web.routes.connections import router as connections_router # noqa: E402
from web.routes.vk_catalog import router as vk_catalog_router # noqa: E402
+from web.routes.logs import router as logs_router # noqa: E402
app.include_router(auth_router)
app.include_router(reset_router)
@@ -48,6 +49,7 @@ app.include_router(admin_router)
app.include_router(catalog_router)
app.include_router(connections_router)
app.include_router(vk_catalog_router)
+app.include_router(logs_router)
# ── Catalog redirect ─────────────────────────────────────────────────────────
diff --git a/web/migrations/versions/0007_api_logs.py b/web/migrations/versions/0007_api_logs.py
new file mode 100644
index 0000000..fbc1a8e
--- /dev/null
+++ b/web/migrations/versions/0007_api_logs.py
@@ -0,0 +1,32 @@
+"""Add api_logs table for request/response logging."""
+revision = "0007"
+down_revision = "0006"
+branch_labels = None
+depends_on = None
+
+import sqlalchemy as sa
+from alembic import op
+
+
+def upgrade():
+ op.create_table(
+ "api_logs",
+ sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
+ sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
+ sa.Column("service", sa.String(20), nullable=False),
+ sa.Column("method", sa.String(10), nullable=False),
+ sa.Column("url", sa.String(1024), nullable=False),
+ sa.Column("request_body", sa.Text, nullable=True),
+ sa.Column("response_status", sa.Integer, nullable=True),
+ sa.Column("response_body", sa.Text, nullable=True),
+ sa.Column("duration_ms", sa.Integer, nullable=True),
+ sa.Column("created_at", sa.DateTime, nullable=False, server_default=sa.func.now()),
+ )
+ op.create_index("ix_api_logs_user_service", "api_logs", ["user_id", "service"])
+ op.create_index("ix_api_logs_created_at", "api_logs", ["created_at"])
+
+
+def downgrade():
+ op.drop_index("ix_api_logs_created_at", "api_logs")
+ op.drop_index("ix_api_logs_user_service", "api_logs")
+ op.drop_table("api_logs")
diff --git a/web/models/connections.py b/web/models/connections.py
index 564b34f..c511b82 100644
--- a/web/models/connections.py
+++ b/web/models/connections.py
@@ -3,6 +3,7 @@ from sqlalchemy import (
Numeric, String, Text, UniqueConstraint, func,
)
+
from web.database import Base
@@ -175,3 +176,23 @@ class CachedProduct(Base):
UniqueConstraint("user_id", "evotor_id", name="uq_cached_products_user_evotor"),
Index("ix_cached_products_user_store_group", "user_id", "store_evotor_id", "group_evotor_id"),
)
+
+
+class ApiLog(Base):
+ __tablename__ = "api_logs"
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
+ service = Column(String(20), nullable=False) # "evotor" | "vk"
+ method = Column(String(10), nullable=False) # "GET" | "POST"
+ url = Column(String(1024), nullable=False)
+ request_body = Column(Text, nullable=True)
+ response_status = Column(Integer, nullable=True)
+ response_body = Column(Text, nullable=True)
+ duration_ms = Column(Integer, nullable=True)
+ created_at = Column(DateTime, nullable=False, server_default=func.now())
+
+ __table_args__ = (
+ Index("ix_api_logs_user_service", "user_id", "service"),
+ Index("ix_api_logs_created_at", "created_at"),
+ )
diff --git a/web/routes/connections.py b/web/routes/connections.py
index 8156a9b..be06dbf 100644
--- a/web/routes/connections.py
+++ b/web/routes/connections.py
@@ -3,6 +3,7 @@ from datetime import datetime, timedelta, timezone
from urllib.parse import urlencode
import httpx
+import web.lib.api_logger as api_logger
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from sqlalchemy.orm import Session
@@ -306,8 +307,9 @@ async def connections_evotor_test(request: Request, db: Session = Depends(get_db
return JSONResponse({"ok": False, "message": "Подключение не настроено"})
try:
- r = httpx.get(
+ r = api_logger.get(
"https://api.evotor.ru/stores",
+ user_id=user.id,
headers={
"Authorization": f"Bearer {conn.access_token}",
"Accept": "application/vnd.evotor.v2+json",
@@ -344,8 +346,9 @@ async def connections_vk_test(request: Request, db: Session = Depends(get_db)):
if not conn.vk_user_id:
return JSONResponse({"ok": False, "message": "Укажите ID сообщества для проверки подключения."})
- r = httpx.get(
+ r = api_logger.get(
"https://api.vk.com/method/groups.getById",
+ user_id=user.id,
params={
"group_id": conn.vk_user_id,
"fields": "market",
diff --git a/web/routes/logs.py b/web/routes/logs.py
new file mode 100644
index 0000000..67e2263
--- /dev/null
+++ b/web/routes/logs.py
@@ -0,0 +1,81 @@
+"""API request/response log viewer (admin only)."""
+from datetime import datetime, timedelta
+
+from fastapi import APIRouter, Depends, Request
+from fastapi.responses import RedirectResponse
+from sqlalchemy.orm import Session
+
+from web.auth.session import get_current_user
+from web.database import get_db
+from web.models.connections import ApiLog
+from web.templates_env import templates
+
+router = APIRouter()
+
+PAGE_SIZE = 50
+
+
+def _render(request, template, ctx):
+ return templates.TemplateResponse(template, {"request": request, **ctx})
+
+
+@router.get("/admin/logs")
+async def admin_logs(
+ request: Request,
+ db: Session = Depends(get_db),
+ service: str = "",
+ method: str = "",
+ status: str = "",
+ q: str = "",
+ page: int = 1,
+ hours: int = 168,
+):
+ try:
+ user = get_current_user(request, db)
+ except Exception:
+ return RedirectResponse("/login", 303)
+
+ since = datetime.utcnow() - timedelta(hours=hours)
+ query = db.query(ApiLog).filter(ApiLog.created_at >= since)
+
+ if service:
+ query = query.filter(ApiLog.service == service)
+ if method:
+ query = query.filter(ApiLog.method == method)
+ if status:
+ try:
+ st = int(status)
+ query = query.filter(ApiLog.response_status == st)
+ except ValueError:
+ if status == "error":
+ query = query.filter(ApiLog.response_status >= 400)
+ elif status == "ok":
+ query = query.filter(ApiLog.response_status < 400)
+ if q:
+ like = f"%{q}%"
+ query = query.filter(
+ ApiLog.url.like(like) | ApiLog.response_body.like(like)
+ )
+
+ total = query.count()
+ logs = (
+ query.order_by(ApiLog.created_at.desc())
+ .offset((page - 1) * PAGE_SIZE)
+ .limit(PAGE_SIZE)
+ .all()
+ )
+ total_pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE)
+
+ return _render(request, "admin/logs.html", {
+ "user": user,
+ "logs": logs,
+ "total": total,
+ "page": page,
+ "total_pages": total_pages,
+ "page_size": PAGE_SIZE,
+ "filter_service": service,
+ "filter_method": method,
+ "filter_status": status,
+ "filter_q": q,
+ "filter_hours": hours,
+ })
diff --git a/web/tasks/vk_catalog.py b/web/tasks/vk_catalog.py
index 574c837..5c7493e 100644
--- a/web/tasks/vk_catalog.py
+++ b/web/tasks/vk_catalog.py
@@ -5,9 +5,9 @@ for every connected user and upsert into vk_cached_* tables.
import logging
from datetime import datetime, timezone
-import httpx
from celery import shared_task
+import web.lib.api_logger as api_logger
from web.config import settings
from web.database import SessionLocal
from web.models.connections import VkCachedAlbum, VkCachedProduct, VkConnection
@@ -21,9 +21,9 @@ def _now() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None)
-def _vk_get(method: str, params: dict, token: str) -> dict:
+def _vk_get(method: str, params: dict, token: str, user_id: int | None = None) -> dict:
params = {**params, "access_token": token, "v": settings.VK_API_VERSION}
- r = httpx.get(f"{VK_API}/{method}", params=params, timeout=20)
+ r = api_logger.get(f"{VK_API}/{method}", user_id=user_id, params=params, timeout=20)
r.raise_for_status()
return r.json()
@@ -34,7 +34,7 @@ def _sync_user(db, user_id: int, token: str, group_id: str) -> None:
# ── albums ────────────────────────────────────────────────────────────────
try:
- data = _vk_get("market.getAlbums", {"owner_id": owner_id, "count": 100}, token)
+ data = _vk_get("market.getAlbums", {"owner_id": owner_id, "count": 100}, token, user_id=user_id)
except Exception as e:
logger.warning("user=%s vk fetch albums failed: %s", user_id, e)
return
@@ -83,6 +83,7 @@ def _sync_user(db, user_id: int, token: str, group_id: str) -> None:
"market.get",
{"owner_id": owner_id, "count": 200, "offset": offset, "extended": 1},
token,
+ user_id=user_id,
)
except Exception as e:
logger.warning("user=%s vk fetch products (extended) failed: %s", user_id, e)
diff --git a/web/tasks/vk_sync.py b/web/tasks/vk_sync.py
index 02f7383..e120957 100644
--- a/web/tasks/vk_sync.py
+++ b/web/tasks/vk_sync.py
@@ -10,9 +10,9 @@ import os
from datetime import datetime, timezone
from decimal import Decimal
-import httpx
from celery import shared_task
+import web.lib.api_logger as api_logger
from web.config import settings
from web.database import SessionLocal
from web.models.connections import (
@@ -63,14 +63,14 @@ def _name_for_vk(name: str) -> str:
return name.replace(";", ",")
-def _vk_post(method: str, data: dict, token: str) -> dict:
+def _vk_post(method: str, data: dict, token: str, user_id: int | None = None) -> dict:
data = {**data, "access_token": token, "v": settings.VK_API_VERSION}
- r = httpx.post(f"{VK_API}/{method}", data=data, timeout=30)
+ r = api_logger.post(f"{VK_API}/{method}", user_id=user_id, data=data, timeout=30)
r.raise_for_status()
return r.json()
-def _upload_photo(token: str, group_id: str) -> str | None:
+def _upload_photo(token: str, group_id: str, user_id: int | None = None) -> str | None:
"""Upload the default product photo and return photo_id, or None on failure."""
photo_path = settings.VK_DEFAULT_PHOTO_PATH
if not os.path.exists(photo_path):
@@ -78,7 +78,7 @@ def _upload_photo(token: str, group_id: str) -> str | None:
return None
try:
# Step 1: get upload URL
- resp = _vk_post("market.getProductPhotoUploadServer", {"group_id": group_id}, token)
+ resp = _vk_post("market.getProductPhotoUploadServer", {"group_id": group_id}, token, user_id=user_id)
if "error" in resp:
logger.warning("getProductPhotoUploadServer error: %s", resp["error"])
return None
@@ -86,12 +86,12 @@ def _upload_photo(token: str, group_id: str) -> str | None:
# Step 2: upload file
with open(photo_path, "rb") as f:
- up = httpx.post(upload_url, files={"file": f}, timeout=30)
+ up = api_logger.post(upload_url, user_id=user_id, files={"file": f}, timeout=30)
up.raise_for_status()
upload_obj = up.text
# Step 3: save
- resp2 = _vk_post("market.saveProductPhoto", {"upload_response": upload_obj}, token)
+ resp2 = _vk_post("market.saveProductPhoto", {"upload_response": upload_obj}, token, user_id=user_id)
if "error" in resp2:
logger.warning("saveProductPhoto error: %s", resp2["error"])
return None
@@ -104,7 +104,7 @@ def _upload_photo(token: str, group_id: str) -> str | None:
def _get_photo_id(user_id: int, token: str, group_id: str) -> str | None:
"""Upload photo once per sync run per user, cache the result."""
if user_id not in _PHOTO_CACHE:
- _PHOTO_CACHE[user_id] = _upload_photo(token, group_id)
+ _PHOTO_CACHE[user_id] = _upload_photo(token, group_id, user_id=user_id)
return _PHOTO_CACHE[user_id]
@@ -142,7 +142,7 @@ def _ensure_album(db, user_id: int, vk_group_id: str, group_name: str, token: st
resp = _vk_post("market.addAlbum", {
"owner_id": f"-{vk_group_id}",
"title": group_name,
- }, token)
+ }, token, user_id=user_id)
if "error" in resp:
logger.warning("market.addAlbum error for '%s': %s", group_name, resp["error"])
return None
@@ -212,7 +212,7 @@ def _sync_product(
"category_id": settings.VK_CATEGORY_ID,
"price": price_kopecks,
"stock_amount": stock,
- }, token)
+ }, token, user_id=user_id)
if "error" in resp:
logger.warning("market.edit error product=%s: %s", product.evotor_id, resp["error"])
return
@@ -223,12 +223,12 @@ def _sync_product(
"owner_id": owner_id,
"item_id": product.vk_product_id,
"album_ids": old_album_id,
- }, token)
+ }, token, user_id=user_id)
resp_album = _vk_post("market.addToAlbum", {
"owner_id": owner_id,
"item_ids": product.vk_product_id,
"album_ids": album_id,
- }, token)
+ }, token, user_id=user_id)
if "error" in resp_album:
logger.warning("market.addToAlbum error product=%s: %s", product.evotor_id, resp_album["error"])
else:
@@ -255,7 +255,7 @@ def _sync_product(
"price": price_kopecks,
"main_photo_id": photo_id,
"stock_amount": stock,
- }, token)
+ }, token, user_id=user_id)
if "error" in resp:
logger.warning("market.add error product=%s: %s", product.evotor_id, resp["error"])
return
@@ -269,7 +269,7 @@ def _sync_product(
"owner_id": owner_id,
"item_ids": vk_item_id,
"album_ids": album_id,
- }, token)
+ }, token, user_id=user_id)
if "error" in resp2:
logger.warning("market.addToAlbum error product=%s: %s", product.evotor_id, resp2["error"])
@@ -310,7 +310,7 @@ def _delete_orphans(db, user_id, vk_group_id, owned_ids, token, results):
resp = _vk_post("market.delete", {
"owner_id": owner_id,
"item_id": vk_p.vk_product_id,
- }, token)
+ }, token, user_id=user_id)
if "error" in resp:
logger.warning("market.delete error id=%s: %s", vk_p.vk_product_id, resp["error"])
results["errors"] += 1
diff --git a/web/templates/admin/logs.html b/web/templates/admin/logs.html
new file mode 100644
index 0000000..5f86a7c
--- /dev/null
+++ b/web/templates/admin/logs.html
@@ -0,0 +1,147 @@
+{% extends "base.html" %}
+{% block title %}API Логи — ЭВОСИНК{% endblock %}
+
+{% block content %}
+
+
API Логи
+ Найдено: {{ total }}
+
+
+{# ── filters ── #}
+
+
+
+ {% if logs %}
+
+
+ {# ── pagination ── #}
+ {% if total_pages > 1 %}
+
+ {% if page > 1 %}
+
← Назад
+ {% endif %}
+
Стр. {{ page }} / {{ total_pages }}
+ {% if page < total_pages %}
+
Вперёд →
+ {% endif %}
+
+ {% endif %}
+
+ {% else %}
+
+
+
Записей не найдено за выбранный период.
+
+ {% endif %}
+
+
+
+
+
+{% endblock %}
diff --git a/web/templates/base.html b/web/templates/base.html
index fb26005..e3b179c 100644
--- a/web/templates/base.html
+++ b/web/templates/base.html
@@ -22,6 +22,7 @@
Синхронизация
{% if user.role in ('admin', 'system') %}
Админ
+ Логи
{% endif %}
Личный кабинет
Выход
@@ -40,6 +41,7 @@
Синхронизация
{% if user.role in ('admin', 'system') %}
Админ
+ Логи
{% endif %}
Личный кабинет
Выход