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 filter_service or filter_method or filter_status or filter_q or filter_hours != 24 %} + Сбросить + {% endif %} +
+ +
+ {% if logs %} +
+ + + + + + + + + + + + + + {% for log in logs %} + {% set is_error = log.response_status and log.response_status >= 400 %} + + + + + + + + + + + + + {% endfor %} + +
ВремяСервисМетодСтатусМсURL
{{ log.created_at | datefmt }} + + {{ log.service }} + + {{ log.method }} + {% if log.response_status %} + {{ log.response_status }} + {% else %} + + {% endif %} + {{ log.duration_ms if log.duration_ms is not none else '—' }} + {{ log.url }} +
+
+ + {# ── 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 %}
  • Личный кабинет
  • Выход