From 8abfa4c5ef4327b4e04def435ef9ba54d9ff773b Mon Sep 17 00:00:00 2001 From: liumangmang Date: Wed, 1 Jul 2026 15:04:32 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E9=A1=B5=E9=9D=A2=E4=B8=8E=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E7=9B=91=E6=8E=A7=E7=9C=8B=E6=9D=BF=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?HTTP=E6=96=B9=E6=B3=95=E8=BF=87=E6=BB=A4=E5=92=8C=E5=AE=9E?= =?UTF-8?q?=E6=97=B6=E8=BD=AE=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/config.py | 3 + backend/app/database.py | 4 +- backend/app/main.py | 3 +- backend/app/models/__init__.py | 1 + backend/app/models/external_api_log.py | 28 + backend/app/routers/external_api_logs.py | 161 +++ backend/app/routers/upstreams.py | 8 + backend/app/routers/websites.py | 2 + backend/app/schemas/external_api_log.py | 37 + backend/app/services/external_api_logger.py | 74 ++ backend/app/services/scheduler.py | 29 + backend/app/services/upstream_client.py | 81 +- backend/app/services/website_client.py | 79 +- backend/app/services/website_sync.py | 6 + backend/test_external_api_log.py | 677 ++++++++++ frontend/src/api/index.ts | 58 + frontend/src/components/AppLayout.vue | 7 + frontend/src/router/index.ts | 1 + frontend/src/views/ExternalApiLogs.vue | 1241 +++++++++++++++++++ 19 files changed, 2463 insertions(+), 37 deletions(-) create mode 100644 backend/app/models/external_api_log.py create mode 100644 backend/app/routers/external_api_logs.py create mode 100644 backend/app/schemas/external_api_log.py create mode 100644 backend/app/services/external_api_logger.py create mode 100644 backend/test_external_api_log.py create mode 100644 frontend/src/views/ExternalApiLogs.vue diff --git a/backend/app/config.py b/backend/app/config.py index 58b61fe..d8663d7 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -15,6 +15,9 @@ class Settings(BaseSettings): tz: str = "Asia/Shanghai" # consecutive failures before upstream goes unhealthy unhealthy_threshold: int = 3 + # external API log retention + external_api_log_retention_days: int = 30 + external_api_log_slow_ms: int = 3000 @property def cors_origin_list(self) -> list[str]: diff --git a/backend/app/database.py b/backend/app/database.py index 73d151d..6dfca2e 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -41,7 +41,7 @@ def get_db(): def init_db(): """Create all tables.""" # import models so SQLAlchemy registers them - from app.models import admin_user, upstream, snapshot, webhook_config, notification_log, custom_page, website, revoked_token, upstream_key # noqa: F401 + from app.models import admin_user, upstream, snapshot, webhook_config, notification_log, custom_page, website, revoked_token, upstream_key, external_api_log # noqa: F401 Base.metadata.create_all(bind=engine) _ensure_indexes() _migrate_custom_pages() @@ -58,6 +58,8 @@ _NEW_INDEXES = [ "CREATE INDEX IF NOT EXISTS ix_sync_binding_created ON website_sync_logs(binding_id, created_at DESC)", "CREATE INDEX IF NOT EXISTS ix_upstream_enabled ON upstreams(enabled)", "CREATE INDEX IF NOT EXISTS ix_key_upstream_name ON upstream_generated_keys(upstream_id, key_name)", + "CREATE INDEX IF NOT EXISTS ix_external_api_logs_created_at_desc ON external_api_logs(created_at DESC)", + "CREATE INDEX IF NOT EXISTS ix_external_api_logs_direction_created ON external_api_logs(direction, created_at DESC)", ] diff --git a/backend/app/main.py b/backend/app/main.py index 69d9e12..a5f4fab 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -15,7 +15,7 @@ from app.models.admin_user import AdminUser from app.database import SessionLocal from app.utils.auth import hash_password, verify_password, validate_password_supported from app.services.scheduler import start_scheduler, stop_scheduler -from app.routers import auth, upstreams, webhooks, logs, custom_pages, websites, auth_capture +from app.routers import auth, upstreams, webhooks, logs, custom_pages, websites, auth_capture, external_api_logs logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s") logger = logging.getLogger(__name__) @@ -97,6 +97,7 @@ app.include_router(logs.router) app.include_router(custom_pages.router) app.include_router(websites.router) app.include_router(auth_capture.router) +app.include_router(external_api_logs.router) @app.get("/healthz") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 3c159a6..799fe7c 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1 +1,2 @@ # models package +from app.models.external_api_log import ExternalApiLog diff --git a/backend/app/models/external_api_log.py b/backend/app/models/external_api_log.py new file mode 100644 index 0000000..b22e5e9 --- /dev/null +++ b/backend/app/models/external_api_log.py @@ -0,0 +1,28 @@ +from datetime import datetime, timezone +from typing import Optional +from sqlalchemy import Index, Integer, String, DateTime, Boolean, text +from sqlalchemy.orm import mapped_column, Mapped +from app.database import Base + + +class ExternalApiLog(Base): + __tablename__ = "external_api_logs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + direction: Mapped[str] = mapped_column(String(32), index=True) # "upstream" | "website" + target_type: Mapped[str] = mapped_column(String(32), index=True) # "upstream" | "website" + target_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True) + target_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + method: Mapped[str] = mapped_column(String(16)) + path: Mapped[str] = mapped_column(String(1024)) + url_host: Mapped[str] = mapped_column(String(255)) + status_code: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True) + success: Mapped[bool] = mapped_column(Boolean, default=True, index=True) + duration_ms: Mapped[int] = mapped_column(Integer) + error_type: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + error_message: Mapped[Optional[str]] = mapped_column(String(1024), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), index=True) + + __table_args__ = ( + Index("ix_external_api_logs_created_at_desc", text("created_at DESC")), + ) diff --git a/backend/app/routers/external_api_logs.py b/backend/app/routers/external_api_logs.py new file mode 100644 index 0000000..3e6cde9 --- /dev/null +++ b/backend/app/routers/external_api_logs.py @@ -0,0 +1,161 @@ +"""External API logs listing with filters and summary.""" +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import List, Optional + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import cast, case, func, Integer +from sqlalchemy.orm import Session + +from app.database import get_db +from app.models.external_api_log import ExternalApiLog +from app.schemas.external_api_log import ExternalApiLogResponse, ExternalApiLogSummaryItem +from app.utils.auth import get_current_user +from app.config import get_settings + +router = APIRouter(prefix="/api/external-api-logs", tags=["external_api_logs"]) + + +@router.get("", response_model=List[ExternalApiLogResponse]) +def list_external_api_logs( + direction: Optional[str] = Query(None), + target_type: Optional[str] = Query(None), + target_id: Optional[int] = Query(None), + method: Optional[str] = Query(None), + success: Optional[bool] = Query(None), + status_code: Optional[int] = Query(None), + path: Optional[str] = Query(None), + limit: int = Query(100, le=500), + offset: int = Query(0), + db: Session = Depends(get_db), + _=Depends(get_current_user), +): + q = db.query(ExternalApiLog) + if direction: + q = q.filter(ExternalApiLog.direction == direction) + if target_type: + q = q.filter(ExternalApiLog.target_type == target_type) + if target_id is not None: + q = q.filter(ExternalApiLog.target_id == target_id) + if method and isinstance(method, str): + q = q.filter(ExternalApiLog.method == method.upper()) + if success is not None: + q = q.filter(ExternalApiLog.success == success) + if status_code is not None: + q = q.filter(ExternalApiLog.status_code == status_code) + if path: + q = q.filter(ExternalApiLog.path.like(f"%{path}%")) + logs = q.order_by(ExternalApiLog.created_at.desc()).offset(offset).limit(limit).all() + return logs + + +@router.get("/count") +def count_external_api_logs( + direction: Optional[str] = Query(None), + target_type: Optional[str] = Query(None), + target_id: Optional[int] = Query(None), + method: Optional[str] = Query(None), + success: Optional[bool] = Query(None), + status_code: Optional[int] = Query(None), + path: Optional[str] = Query(None), + db: Session = Depends(get_db), + _=Depends(get_current_user), +): + q = db.query(ExternalApiLog) + if direction: + q = q.filter(ExternalApiLog.direction == direction) + if target_type: + q = q.filter(ExternalApiLog.target_type == target_type) + if target_id is not None: + q = q.filter(ExternalApiLog.target_id == target_id) + if method and isinstance(method, str): + q = q.filter(ExternalApiLog.method == method.upper()) + if success is not None: + q = q.filter(ExternalApiLog.success == success) + if status_code is not None: + q = q.filter(ExternalApiLog.status_code == status_code) + if path: + q = q.filter(ExternalApiLog.path.like(f"%{path}%")) + return {"total": q.count()} + + +@router.get("/summary", response_model=List[ExternalApiLogSummaryItem]) +def get_external_api_log_summary( + slow_ms: Optional[int] = Query(None), + hours: int = Query(24, ge=1, le=720), + limit: int = Query(50, ge=1, le=200), + db: Session = Depends(get_db), + _=Depends(get_current_user), +): + """Return aggregated summary grouped by direction + target + method + path. + + - hours: time window (default 24, max 720). None means no time filter. + - limit: max rows returned (default 50, max 200). + """ + settings = get_settings() + slow_threshold = slow_ms if slow_ms is not None else settings.external_api_log_slow_ms + + q = db.query(*_summary_cols(slow_threshold)) + cutoff = datetime.now(timezone.utc) - timedelta(hours=hours) + q = q.filter(ExternalApiLog.created_at >= cutoff) + q = q.group_by( + ExternalApiLog.direction, + ExternalApiLog.target_type, + ExternalApiLog.target_id, + ExternalApiLog.target_name, + ExternalApiLog.method, + ExternalApiLog.path, + ).order_by(func.sum(ExternalApiLog.duration_ms).desc()) + q = q.limit(limit) + rows = q.all() + + return [ + ExternalApiLogSummaryItem( + direction=row.direction, + target_type=row.target_type, + target_id=row.target_id, + target_name=row.target_name, + method=row.method, + path=row.path, + call_count=row.call_count, + success_count=row.success_count or 0, + fail_count=row.fail_count or 0, + avg_duration_ms=round(float(row.avg_duration_ms or 0), 1), + max_duration_ms=row.max_duration_ms or 0, + slow_count=row.slow_count or 0, + ) + for row in rows + ] + + +def _summary_cols(slow_threshold: int) -> list: + return [ + ExternalApiLog.direction, + ExternalApiLog.target_type, + ExternalApiLog.target_id, + ExternalApiLog.target_name, + ExternalApiLog.method, + ExternalApiLog.path, + func.count(ExternalApiLog.id).label("call_count"), + func.sum(cast(ExternalApiLog.success, Integer)).label("success_count"), + func.sum(cast(1 - cast(ExternalApiLog.success, Integer), Integer)).label("fail_count"), + func.avg(ExternalApiLog.duration_ms).label("avg_duration_ms"), + func.max(ExternalApiLog.duration_ms).label("max_duration_ms"), + func.sum(case((ExternalApiLog.duration_ms >= slow_threshold, 1), else_=0)).label("slow_count"), + ] + + +def clean_expired_logs(db: Session) -> int: + """Delete external API logs older than the configured retention period. + + Returns the number of deleted rows. + """ + settings = get_settings() + cutoff = datetime.now(timezone.utc) - timedelta(days=settings.external_api_log_retention_days) + deleted = db.query(ExternalApiLog).filter( + ExternalApiLog.created_at < cutoff + ).delete(synchronize_session=False) + if deleted: + db.commit() + return deleted diff --git a/backend/app/routers/upstreams.py b/backend/app/routers/upstreams.py index eaabbcd..e42eec6 100644 --- a/backend/app/routers/upstreams.py +++ b/backend/app/routers/upstreams.py @@ -195,6 +195,8 @@ def list_generated_keys(uid: int, db: Session = Depends(get_db), _=Depends(get_c auth_config=auth_config, timeout=float(upstream.timeout_seconds), on_auth_config_update=lambda updated: _persist_auth_config_update(db, upstream, updated), + target_id=upstream.id, + target_name=upstream.name, ) as client: client.login() for prefix in website_sync._fetch_remote_managed_prefixes(db, uid): @@ -628,6 +630,8 @@ def generate_keys_by_groups( auth_config=auth_config, timeout=float(u.timeout_seconds), on_auth_config_update=lambda updated: _persist_auth_config_update(db, u, updated), + target_id=u.id, + target_name=u.name, ) as client: try: client.login() @@ -679,6 +683,8 @@ def _test_upstream_core(db: Session, u: Upstream) -> UpstreamBatchActionItem: auth_config=auth_config, timeout=float(u.timeout_seconds), on_auth_config_update=lambda updated: _persist_auth_config_update(db, u, updated), + target_id=u.id, + target_name=u.name, ) as client: client.login() groups = client.get_available_groups(u.groups_endpoint) @@ -719,6 +725,8 @@ def _check_now_core(db: Session, u: Upstream) -> tuple[str, bool]: auth_config=auth_config, timeout=float(u.timeout_seconds), on_auth_config_update=lambda updated: _persist_auth_config_update(db, u, updated), + target_id=u.id, + target_name=u.name, ) as client: client.login() groups = client.get_available_groups(u.groups_endpoint) diff --git a/backend/app/routers/websites.py b/backend/app/routers/websites.py index 6b4f098..6aec13e 100644 --- a/backend/app/routers/websites.py +++ b/backend/app/routers/websites.py @@ -140,6 +140,8 @@ def _client(row: Website) -> Sub2ApiWebsiteClient: auth_type=row.auth_type, auth_config=json.loads(row.auth_config_json or "{}"), timeout=float(row.timeout_seconds), + target_id=row.id, + target_name=row.name, ) diff --git a/backend/app/schemas/external_api_log.py b/backend/app/schemas/external_api_log.py new file mode 100644 index 0000000..3d33e70 --- /dev/null +++ b/backend/app/schemas/external_api_log.py @@ -0,0 +1,37 @@ +from datetime import datetime +from typing import List, Optional +from pydantic import BaseModel + + +class ExternalApiLogResponse(BaseModel): + id: int + direction: str + target_type: str + target_id: Optional[int] = None + target_name: Optional[str] = None + method: str + path: str + url_host: str + status_code: Optional[int] = None + success: bool + duration_ms: int + error_type: Optional[str] = None + error_message: Optional[str] = None + created_at: datetime + + model_config = {"from_attributes": True} + + +class ExternalApiLogSummaryItem(BaseModel): + direction: str + target_type: str + target_id: Optional[int] = None + target_name: Optional[str] = None + method: str + path: str + call_count: int + success_count: int + fail_count: int + avg_duration_ms: float + max_duration_ms: int + slow_count: int diff --git a/backend/app/services/external_api_logger.py b/backend/app/services/external_api_logger.py new file mode 100644 index 0000000..ac36286 --- /dev/null +++ b/backend/app/services/external_api_logger.py @@ -0,0 +1,74 @@ +"""Log external API calls to the database and console.""" +from __future__ import annotations + +import logging +from datetime import datetime, timezone +from typing import Optional + +from app.database import SessionLocal +from app.models.external_api_log import ExternalApiLog + +logger = logging.getLogger(__name__) + + +def log_external_api_call( + direction: str, + target_type: str, + method: str, + path: str, + url_host: str, + duration_ms: int, + status_code: Optional[int] = None, + success: bool = True, + target_id: Optional[int] = None, + target_name: Optional[str] = None, + error_type: Optional[str] = None, + error_message: Optional[str] = None, +) -> None: + """Persist an external API call log entry and write a structured console log. + + No request/response bodies, tokens, cookies, or API keys are recorded. + """ + # Console log (structured, for Docker log aggregation) + logger.info( + "api_log direction=%s target_type=%s target_id=%s target_name=%s " + "method=%s path=%s url_host=%s status_code=%s success=%s " + "duration_ms=%d error_type=%s error_message=%s", + direction, + target_type, + target_id, + target_name or "", + method, + path, + url_host, + status_code or "", + success, + duration_ms, + error_type or "", + (error_message or "")[:200], + ) + + # DB persistence + db = SessionLocal() + try: + log = ExternalApiLog( + direction=direction, + target_type=target_type, + target_id=target_id, + target_name=target_name, + method=method, + path=path, + url_host=url_host, + status_code=status_code, + success=success, + duration_ms=duration_ms, + error_type=error_type, + error_message=error_message, + created_at=datetime.now(timezone.utc), + ) + db.add(log) + db.commit() + except Exception: + logger.exception("failed to persist external API log entry") + finally: + db.close() diff --git a/backend/app/services/scheduler.py b/backend/app/services/scheduler.py index 0d3fef6..f36d4ae 100644 --- a/backend/app/services/scheduler.py +++ b/backend/app/services/scheduler.py @@ -19,6 +19,7 @@ from app.services.snapshot_service import diff_snapshots, prune_snapshots from app.services import webhook_service from app.services import website_sync from app.config import get_settings +from app.routers.external_api_logs import clean_expired_logs logger = logging.getLogger(__name__) @@ -77,6 +78,8 @@ def _check_upstream(upstream_id: int) -> None: auth_config=auth_config, timeout=float(upstream.timeout_seconds), on_auth_config_update=lambda updated: _persist_auth_config_update(upstream, updated), + target_id=upstream.id, + target_name=upstream.name, ) as client: try: client.login() @@ -258,6 +261,8 @@ def _sync_upstream_keys(upstream_id: int, snapshot: dict[str, Any], captured_at: auth_config=auth_config, timeout=float(upstream.timeout_seconds), on_auth_config_update=lambda updated: _persist_auth_config_update(upstream, updated), + target_id=upstream.id, + target_name=upstream.name, ) as client: client.login() remote_key_ids = website_sync._fetch_remote_managed_key_ids(db, client, upstream_id) @@ -328,11 +333,35 @@ def start_scheduler() -> None: upstreams = db.query(Upstream).filter(Upstream.enabled == True).all() for u in upstreams: refresh_upstream(u.id, u.check_interval_seconds, u.enabled) + # Daily cleanup of expired external API logs + _scheduler.add_job( + _cleanup_external_logs, + "interval", + hours=24, + id="cleanup_external_api_logs", + replace_existing=True, + coalesce=True, + max_instances=1, + misfire_grace_time=3600, + ) logger.info("scheduler started with %d upstream job(s)", len(upstreams)) finally: db.close() +def _cleanup_external_logs() -> None: + """Delete external API logs older than the configured retention period.""" + db = SessionLocal() + try: + deleted = clean_expired_logs(db) + if deleted: + logger.info("cleaned %d expired external API log(s)", deleted) + except Exception: + logger.exception("failed to clean expired external API logs") + finally: + db.close() + + def stop_scheduler() -> None: if _scheduler.running: _scheduler.shutdown(wait=True) diff --git a/backend/app/services/upstream_client.py b/backend/app/services/upstream_client.py index f2870f6..99e2698 100644 --- a/backend/app/services/upstream_client.py +++ b/backend/app/services/upstream_client.py @@ -3,13 +3,18 @@ from __future__ import annotations import hashlib import json +import logging import re import time from typing import Any, Callable, Optional -from urllib.parse import urljoin +from urllib.parse import urljoin, urlparse + import httpx from app.utils.number import decimal_string +from app.services.external_api_logger import log_external_api_call + +logger = logging.getLogger(__name__) class UpstreamError(RuntimeError): @@ -429,6 +434,8 @@ class UpstreamClient: auth_config: dict[str, Any], timeout: float = 30.0, on_auth_config_update: Callable[[dict[str, Any]], None] | None = None, + target_id: int | None = None, + target_name: str | None = None, ) -> None: self.base_url = base_url.rstrip("/") self.api_prefix = api_prefix.strip("/") @@ -436,6 +443,8 @@ class UpstreamClient: self.auth_config = auth_config self.timeout = timeout self.on_auth_config_update = on_auth_config_update + self.target_id = target_id + self.target_name = target_name self._token: str = "" self._cookies: dict[str, str] = {} self._new_api_user: str = "" @@ -549,6 +558,51 @@ class UpstreamClient: if changed and self.on_auth_config_update: self.on_auth_config_update(dict(self.auth_config)) + def _do_request(self, method: str, url: str, **kwargs: Any) -> httpx.Response: + """Wrapped self._client.request() with timing and external-API logging. + + Every HTTP call (initial, retry, refresh token) goes through here so + the log is accurate and complete. + + A response with status_code >= 400 is logged as a failure even though + no exception was raised, so callers can check success/failure rates + accurately. Callers still use raise_for_status() for flow control. + """ + started = time.monotonic() + status_code: int | None = None + error_type: str | None = None + error_msg: str | None = None + try: + resp = self._client.request(method, url, **kwargs) + status_code = getattr(resp, "status_code", None) + if status_code is not None and status_code >= 400: + error_type = "HTTPStatus" + error_msg = f"HTTP {status_code}" + return resp + except Exception as exc: + error_type = type(exc).__name__ + error_msg = str(exc)[:500] + if hasattr(exc, "response") and hasattr(exc.response, "status_code"): + status_code = exc.response.status_code + raise + finally: + elapsed = int((time.monotonic() - started) * 1000) + parsed = urlparse(url) + log_external_api_call( + direction="upstream", + target_type="upstream", + method=method, + path=parsed.path or "", + url_host=parsed.hostname or "", + duration_ms=elapsed, + status_code=status_code, + success=error_type is None, + target_id=self.target_id, + target_name=self.target_name, + error_type=error_type, + error_message=error_msg, + ) + def _refresh_sub2api_bearer_token(self) -> bool: if not self._is_sub2api_bearer(): return False @@ -556,7 +610,7 @@ class UpstreamClient: if not refresh_token: return False try: - resp = self._client.request( + resp = self._do_request( "POST", self._url("/auth/refresh"), json={"refresh_token": refresh_token}, @@ -587,7 +641,7 @@ class UpstreamClient: allow_refresh: bool = True, **kwargs: Any, ) -> httpx.Response: - resp = self._client.request( + resp = self._do_request( method, url, headers=self._headers(auth), @@ -602,7 +656,7 @@ class UpstreamClient: and self._is_sub2api_bearer() and self._refresh_sub2api_bearer_token() ): - resp = self._client.request( + resp = self._do_request( method, url, headers=self._headers(auth), @@ -621,18 +675,9 @@ class UpstreamClient: raise UpstreamError("Nox-API endpoint requires Nox-Api-User; please fill user id and retry") url = self._url(path) if body is not None: - resp = self._send_request( - method, - url, - json=body, - auth=auth, - ) + resp = self._send_request(method, url, json=body, auth=auth) else: - resp = self._send_request( - method, - url, - auth=auth, - ) + resp = self._send_request(method, url, auth=auth) resp.raise_for_status() ct = resp.headers.get("content-type", "") if not resp.content: @@ -689,11 +734,7 @@ class UpstreamClient: return items, meta def _request_new_api_token_list(self, path: str, params: dict[str, Any]) -> tuple[list[dict[str, Any]], dict[str, Any]]: - resp = self._send_request( - "GET", - self._url(path), - params=params, - ) + resp = self._send_request("GET", self._url(path), params=params) resp.raise_for_status() data = resp.json() self._ensure_api_success(data, "list New-API tokens") diff --git a/backend/app/services/website_client.py b/backend/app/services/website_client.py index 1dd2c01..d726a2c 100644 --- a/backend/app/services/website_client.py +++ b/backend/app/services/website_client.py @@ -1,13 +1,16 @@ from __future__ import annotations import logging +import sys +import time from decimal import Decimal, InvalidOperation, ROUND_HALF_UP from typing import Any -from urllib.parse import quote +from urllib.parse import quote, urlparse import httpx from app.utils.number import fixed_decimal_number, fixed_decimal_string +from app.services.external_api_logger import log_external_api_call logger = logging.getLogger(__name__) @@ -136,12 +139,16 @@ class Sub2ApiWebsiteClient: auth_type: str, auth_config: dict[str, Any], timeout: float = 30.0, + target_id: int | None = None, + target_name: str | None = None, ) -> None: self.base_url = base_url.rstrip("/") self.api_prefix = api_prefix.strip("/") self.auth_type = auth_type self.auth_config = auth_config self.timeout = timeout + self.target_id = target_id + self.target_name = target_name self._client = httpx.Client(timeout=timeout) def close(self) -> None: @@ -171,21 +178,63 @@ class Sub2ApiWebsiteClient: return headers def _request(self, method: str, path: str, body: Any = None) -> Any: + url = self._url(path) + started = time.monotonic() + status_code: int | None = None + error_type: str | None = None + error_msg: str | None = None try: - resp = self._client.request(method, self._url(path), json=body, headers=self._headers()) - resp.raise_for_status() - except httpx.HTTPStatusError as exc: - raise WebsiteError(_friendly_http_error(exc)) from exc - except httpx.TimeoutException as exc: - raise WebsiteError(_friendly_connection_error(exc)) from exc - except httpx.ConnectError as exc: - raise WebsiteError(_friendly_connection_error(exc)) from exc - if not resp.content: - return None - text = resp.text - if "application/json" not in resp.headers.get("content-type", "") and text.lstrip().startswith("<"): - raise WebsiteError(f"{method} {path} 返回了 HTML,请检查接口地址是否正确") - return resp.json() + try: + resp = self._client.request(method, url, json=body, headers=self._headers()) + except httpx.TimeoutException as exc: + error_type, error_msg = type(exc).__name__, str(exc)[:500] + raise WebsiteError(_friendly_connection_error(exc)) from exc + except httpx.ConnectError as exc: + error_type, error_msg = type(exc).__name__, str(exc)[:500] + raise WebsiteError(_friendly_connection_error(exc)) from exc + except httpx.HTTPStatusError as exc: + error_type, error_msg = type(exc).__name__, str(exc)[:500] + status_code = exc.response.status_code + raise WebsiteError(_friendly_http_error(exc)) from exc + status_code = getattr(resp, "status_code", None) + try: + resp.raise_for_status() + except httpx.HTTPStatusError as exc: + error_type, error_msg = type(exc).__name__, str(exc)[:500] + status_code = exc.response.status_code + raise WebsiteError(_friendly_http_error(exc)) from exc + if not resp.content: + return None + text = resp.text + if "application/json" not in resp.headers.get("content-type", "") and text.lstrip().startswith("<"): + error_type = "WebsiteError" + error_msg = f"{method} {path} returned HTML" + raise WebsiteError(f"{method} {path} 返回了 HTML,请检查接口地址是否正确") + return resp.json() + except WebsiteError: + if error_type is None: + error_type = "WebsiteError" + e = sys.exc_info()[1] + if e: + error_msg = str(e)[:500] + raise + finally: + elapsed = int((time.monotonic() - started) * 1000) + parsed = urlparse(url) + log_external_api_call( + direction="website", + target_type="website", + method=method, + path=parsed.path or path, + url_host=parsed.hostname or "", + duration_ms=elapsed, + status_code=status_code, + success=error_type is None, + target_id=self.target_id, + target_name=self.target_name, + error_type=error_type, + error_message=error_msg, + ) def get_groups(self, endpoint: str = "/groups") -> list[dict[str, Any]]: """拉取分组列表,尝试 endpoint 和 fallback /groups/all。""" diff --git a/backend/app/services/website_sync.py b/backend/app/services/website_sync.py index fa61642..6dc6316 100644 --- a/backend/app/services/website_sync.py +++ b/backend/app/services/website_sync.py @@ -93,6 +93,8 @@ def _client_for(website: Website) -> Sub2ApiWebsiteClient: auth_type=website.auth_type, auth_config=json.loads(website.auth_config_json or "{}"), timeout=float(website.timeout_seconds), + target_id=website.id, + target_name=website.name, ) @@ -325,6 +327,8 @@ def _backfill_missing_target_groups_from_remote_accounts( auth_type=website.auth_type, auth_config=json.loads(website.auth_config_json or "{}"), timeout=float(website.timeout_seconds), + target_id=website.id, + target_name=website.name, ) as client: accounts = client.list_accounts() or [] except Exception as exc: @@ -838,6 +842,8 @@ def reconcile_upstream_keys_full(db: Session, upstream_id: int) -> bool: auth_config=auth_config, timeout=float(upstream.timeout_seconds), on_auth_config_update=lambda updated: _persist_upstream_auth_config(upstream, updated), + target_id=upstream.id, + target_name=upstream.name, ) as client: client.login() # 获取远端 Key 列表(支持自定义 managed_prefix) diff --git a/backend/test_external_api_log.py b/backend/test_external_api_log.py new file mode 100644 index 0000000..0e4bff3 --- /dev/null +++ b/backend/test_external_api_log.py @@ -0,0 +1,677 @@ +"""Tests for external API log persistence: logging, query, summary, and cleanup.""" +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from unittest.mock import patch + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from app.database import Base +from app.models.external_api_log import ExternalApiLog + + +@pytest.fixture +def engine(): + e = create_engine("sqlite://", connect_args={"check_same_thread": False}) + # Import all models so they register with Base.metadata + import app.models.admin_user # noqa: F401 + import app.models.upstream # noqa: F401 + import app.models.website # noqa: F401 + import app.models.snapshot # noqa: F401 + import app.models.webhook_config # noqa: F401 + import app.models.notification_log # noqa: F401 + import app.models.custom_page # noqa: F401 + import app.models.revoked_token # noqa: F401 + import app.models.upstream_key # noqa: F401 + import app.models.external_api_log # noqa: F401 + Base.metadata.create_all(bind=e) + yield e + Base.metadata.drop_all(bind=e) + + +@pytest.fixture +def session(engine): + TestingSession = sessionmaker(bind=engine) + db = TestingSession() + try: + yield db + finally: + db.close() + + +# ───────────────────────────────────────────── +# 1. Model: basic insert and read +# ───────────────────────────────────────────── + +def test_insert_and_read(session): + now = datetime.now(timezone.utc) + log = ExternalApiLog( + direction="upstream", + target_type="upstream", + target_id=1, + target_name="test-upstream", + method="GET", + path="/api/v1/models", + url_host="api.test.local", + status_code=200, + success=True, + duration_ms=150, + created_at=now, + ) + session.add(log) + session.commit() + + rows = session.query(ExternalApiLog).all() + assert len(rows) == 1 + assert rows[0].direction == "upstream" + assert rows[0].duration_ms == 150 + assert rows[0].status_code == 200 + assert rows[0].success is True + + +# ───────────────────────────────────────────── +# 2. Filters (direction, target_type, target_id, success, status_code, path) +# ───────────────────────────────────────────── + +def _seed(session, count: int = 1, **overrides) -> list[ExternalApiLog]: + rows = [] + for i in range(count): + defaults = dict( + direction="upstream", + target_type="upstream", + target_id=10 + i, + target_name=f"upstream-{i}", + method="GET", + path=f"/api/v1/endpoint/{i}", + url_host="api.test.local", + status_code=200, + success=True, + duration_ms=100 + i * 10, + created_at=datetime.now(timezone.utc), + ) + defaults.update(overrides) + r = ExternalApiLog(**defaults) + session.add(r) + rows.append(r) + session.commit() + return rows + + +def test_filter_by_direction(session): + _seed(session, 1, direction="upstream") + _seed(session, 1, direction="website") + rows = session.query(ExternalApiLog).filter(ExternalApiLog.direction == "upstream").all() + assert len(rows) == 1 + assert rows[0].direction == "upstream" + + +def test_filter_by_success(session): + _seed(session, 1, success=True) + _seed(session, 1, success=False, status_code=500, error_type="ServerError") + rows = session.query(ExternalApiLog).filter(ExternalApiLog.success == False).all() # noqa: E712 + assert len(rows) == 1 + assert rows[0].success is False + + +def test_filter_by_status_code(session): + _seed(session, 1, status_code=200) + _seed(session, 1, status_code=404, success=False, error_type="NotFound") + rows = session.query(ExternalApiLog).filter(ExternalApiLog.status_code == 404).all() + assert len(rows) == 1 + assert rows[0].status_code == 404 + + +def test_filter_by_target_id(session): + _seed(session, 1, target_id=42) + _seed(session, 1, target_id=99) + rows = session.query(ExternalApiLog).filter(ExternalApiLog.target_id == 42).all() + assert len(rows) == 1 + assert rows[0].target_id == 42 + + +def test_filter_by_path_like(session): + _seed(session, 1, path="/api/keys/list") + _seed(session, 1, path="/api/groups/list") + rows = session.query(ExternalApiLog).filter(ExternalApiLog.path.like("%keys%")).all() + assert len(rows) == 1 + assert "keys" in rows[0].path + + +def test_pagination(session): + _seed(session, 5) + total = session.query(ExternalApiLog).count() + assert total == 5 + page = session.query(ExternalApiLog).order_by(ExternalApiLog.id).offset(0).limit(2).all() + assert len(page) == 2 + page2 = session.query(ExternalApiLog).order_by(ExternalApiLog.id).offset(2).limit(2).all() + assert len(page2) == 2 + + +# ───────────────────────────────────────────── +# 3. Summary aggregation +# ───────────────────────────────────────────── + +def test_summary_aggregation(session): + now = datetime.now(timezone.utc) + # 3 calls to same path: 2 success, 1 fail, various durations + for i in range(3): + session.add(ExternalApiLog( + direction="upstream", + target_type="upstream", + target_id=1, + target_name="agg-test", + method="GET", + path="/api/v1/status", + url_host="api.test.local", + status_code=200 if i < 2 else 500, + success=i < 2, + duration_ms=[100, 200, 300][i], + created_at=now, + )) + # A different path + session.add(ExternalApiLog( + direction="website", + target_type="website", + target_id=2, + target_name="site-2", + method="POST", + path="/api/v1/accounts", + url_host="site.test.local", + status_code=201, + success=True, + duration_ms=500, + created_at=now, + )) + session.commit() + + from sqlalchemy import cast, case, func, Integer + slow_threshold = 250 + cols = [ + ExternalApiLog.direction, + ExternalApiLog.target_type, + ExternalApiLog.target_id, + ExternalApiLog.target_name, + ExternalApiLog.method, + ExternalApiLog.path, + func.count(ExternalApiLog.id).label("call_count"), + func.sum(cast(ExternalApiLog.success, Integer)).label("success_count"), + func.sum(cast(1 - cast(ExternalApiLog.success, Integer), Integer)).label("fail_count"), + func.avg(ExternalApiLog.duration_ms).label("avg_duration_ms"), + func.max(ExternalApiLog.duration_ms).label("max_duration_ms"), + func.sum(case((ExternalApiLog.duration_ms >= slow_threshold, 1), else_=0)).label("slow_count"), + ] + rows = ( + session.query(*cols) + .group_by( + ExternalApiLog.direction, ExternalApiLog.target_type, + ExternalApiLog.target_id, ExternalApiLog.target_name, + ExternalApiLog.method, ExternalApiLog.path, + ) + .order_by(func.sum(ExternalApiLog.duration_ms).desc()) + .all() + ) + + assert len(rows) == 2 + # First row: upstream, 3 calls + r1 = rows[0] + assert r1.direction == "upstream" + assert r1.call_count == 3 + assert r1.success_count == 2 + assert r1.fail_count == 1 + assert r1.slow_count == 1 # only 300ms >= 250 + assert r1.max_duration_ms == 300 + assert 190 <= r1.avg_duration_ms <= 210 # ~200 + + # Second row: website, 1 call + r2 = rows[1] + assert r2.direction == "website" + assert r2.call_count == 1 + assert r2.success_count == 1 + assert r2.fail_count == 0 + + +# ───────────────────────────────────────────── +# 4. Cleanup (retention) +# ───────────────────────────────────────────── + +def test_clean_expired_logs(session): + now = datetime.now(timezone.utc) + # Old record (40 days ago) + session.add(ExternalApiLog( + direction="upstream", target_type="upstream", method="GET", + path="/old", url_host="old.local", + status_code=200, success=True, duration_ms=10, + created_at=now - timedelta(days=40), + )) + # Recent record (5 days ago) + session.add(ExternalApiLog( + direction="upstream", target_type="upstream", method="GET", + path="/recent", url_host="recent.local", + status_code=200, success=True, duration_ms=10, + created_at=now - timedelta(days=5), + )) + session.commit() + assert session.query(ExternalApiLog).count() == 2 + + # Delete records older than 30 days + cutoff = now - timedelta(days=30) + deleted = session.query(ExternalApiLog).filter( + ExternalApiLog.created_at < cutoff + ).delete(synchronize_session=False) + session.commit() + assert deleted == 1 + assert session.query(ExternalApiLog).count() == 1 + remaining = session.query(ExternalApiLog).first() + assert "/recent" in remaining.path + + +# ───────────────────────────────────────────── +# 5. log_external_api_call function +# ───────────────────────────────────────────── + +def test_log_external_api_call_success(session): + """Verify the logging helper writes the expected row.""" + from app.services.external_api_logger import log_external_api_call + + with patch("app.services.external_api_logger.SessionLocal", return_value=session): + log_external_api_call( + direction="upstream", + target_type="upstream", + method="POST", + path="/api/v1/test", + url_host="test.local", + duration_ms=250, + status_code=201, + success=True, + target_id=7, + target_name="gold-upstream", + ) + + rows = session.query(ExternalApiLog).all() + assert len(rows) == 1 + r = rows[0] + assert r.direction == "upstream" + assert r.method == "POST" + assert r.path == "/api/v1/test" + assert r.url_host == "test.local" + assert r.duration_ms == 250 + assert r.status_code == 201 + assert r.success is True + assert r.target_id == 7 + assert r.target_name == "gold-upstream" + assert r.error_type is None + assert r.error_message is None + + +def test_log_external_api_call_failure(session): + """Failure path: error fields populated, success=False.""" + from app.services.external_api_logger import log_external_api_call + + with patch("app.services.external_api_logger.SessionLocal", return_value=session): + log_external_api_call( + direction="website", + target_type="website", + method="GET", + path="/api/v1/groups", + url_host="site.local", + duration_ms=5000, + status_code=502, + success=False, + target_id=3, + target_name="bad-site", + error_type="BadGateway", + error_message="upstream connection refused", + ) + + rows = session.query(ExternalApiLog).all() + assert len(rows) == 1 + r = rows[0] + assert r.direction == "website" + assert r.success is False + assert r.status_code == 502 + assert r.duration_ms == 5000 + assert r.error_type == "BadGateway" + assert r.error_message == "upstream connection refused" + + +def test_log_no_sensitive_data(session): + """Verify no request/response body or credentials are logged.""" + from app.services.external_api_logger import log_external_api_call + + with patch("app.services.external_api_logger.SessionLocal", return_value=session): + log_external_api_call( + direction="upstream", + target_type="upstream", + method="GET", + path="/api/v1/keys", + url_host="secure.local", + duration_ms=100, + status_code=200, + success=True, + ) + + rows = session.query(ExternalApiLog).all() + assert len(rows) == 1 + r = rows[0] + # These fields MUST be empty — we never log bodies or credentials + assert not hasattr(r, "request_body") + assert not hasattr(r, "response_body") + assert not hasattr(r, "authorization") + assert not hasattr(r, "cookie") + assert not hasattr(r, "api_key") + + +# ───────────────────────────────────────────── +# 6. Time window on summary +# ───────────────────────────────────────────── + +def test_summary_time_window(session): + now = datetime.now(timezone.utc) + # Recent (1 hour ago) + session.add(ExternalApiLog( + direction="upstream", target_type="upstream", method="GET", + path="/recent", url_host="h", status_code=200, success=True, + duration_ms=50, created_at=now - timedelta(hours=1), + )) + # Old (48 hours ago) + session.add(ExternalApiLog( + direction="upstream", target_type="upstream", method="GET", + path="/old", url_host="h", status_code=200, success=True, + duration_ms=50, created_at=now - timedelta(hours=48), + )) + session.commit() + + # 24-hour window should only include the recent record + cutoff = now - timedelta(hours=24) + rows = session.query(ExternalApiLog).filter(ExternalApiLog.created_at >= cutoff).all() + assert len(rows) == 1 + assert "/recent" in rows[0].path + + # 72-hour window includes both + cutoff = now - timedelta(hours=72) + rows = session.query(ExternalApiLog).filter(ExternalApiLog.created_at >= cutoff).all() + assert len(rows) == 2 + + +# ───────────────────────────────────────────── +# 7. UpstreamClient integration: 4xx/5xx logged as failure +# ───────────────────────────────────────────── + +def _make_httpx_mock(status_code: int, body: dict | None = None): + """Return a mock for httpx.Client.request that returns a fixed status. + + Used via patch.object(client._client, 'request', ...) which replaces the + bound method, so the signature is (method, url, **kwargs) — no self. + """ + import httpx + + def mock_request(method, url, **kwargs): + req = httpx.Request(method, url) + return httpx.Response(status_code, json=body or {}, request=req) + + return mock_request + + +def test_upstream_client_500_logged_as_failure(): + """UpstreamClient._request() → HTTP 500 → log with success=False, status_code=500.""" + from app.services.upstream_client import UpstreamClient + + captured = {} + + def capture(**kw): + captured.update(kw) + + with patch("app.services.upstream_client.log_external_api_call", side_effect=capture): + client = UpstreamClient( + base_url="http://test.local", + api_prefix="", + auth_type="api_key", + auth_config={"key": "test", "header": "Authorization"}, + target_id=1, + target_name="test-upstream", + ) + with patch.object(client._client, "request", _make_httpx_mock(500)): + try: + client._request("GET", "/api/v1/models") + except Exception: + pass + + assert captured.get("status_code") == 500 + assert captured.get("success") is False + assert captured.get("error_type") == "HTTPStatus" + assert "500" in (captured.get("error_message") or "") + + +def test_upstream_client_429_logged_as_failure(): + """UpstreamClient._request() → HTTP 429 → log with success=False, status_code=429.""" + from app.services.upstream_client import UpstreamClient + + captured = {} + + def capture(**kw): + captured.update(kw) + + with patch("app.services.upstream_client.log_external_api_call", side_effect=capture): + client = UpstreamClient( + base_url="http://test.local", + api_prefix="", + auth_type="api_key", + auth_config={"key": "test", "header": "Authorization"}, + target_id=1, + target_name="test-upstream", + ) + with patch.object(client._client, "request", _make_httpx_mock(429)): + try: + client._request("GET", "/api/v1/rate-limited") + except Exception: + pass + + assert captured.get("status_code") == 429 + assert captured.get("success") is False + assert captured.get("error_type") == "HTTPStatus" + + +def test_upstream_client_200_logged_as_success(): + """UpstreamClient._request() → HTTP 200 → log with success=True, status_code=200.""" + from app.services.upstream_client import UpstreamClient + + captured = {} + + def capture(**kw): + captured.update(kw) + + with patch("app.services.upstream_client.log_external_api_call", side_effect=capture): + client = UpstreamClient( + base_url="http://test.local", + api_prefix="", + auth_type="api_key", + auth_config={"key": "test", "header": "Authorization"}, + target_id=1, + target_name="test-upstream", + ) + with patch.object(client._client, "request", _make_httpx_mock(200, {"data": []})): + client._request("GET", "/api/v1/groups") + + assert captured.get("status_code") == 200 + assert captured.get("success") is True + assert captured.get("error_type") is None + + +# ───────────────────────────────────────────── +# 8. Route-level tests — call handlers directly +# ───────────────────────────────────────────── + +def test_route_list_empty(session): + """list_external_api_logs with no data returns [].""" + from app.routers.external_api_logs import list_external_api_logs + + result = list_external_api_logs( + direction=None, target_type=None, target_id=None, + success=None, status_code=None, path=None, + limit=100, offset=0, db=session, _=None, + ) + assert result == [] + + +def test_route_list_with_filters(session): + """list_external_api_logs with filters returns filtered results.""" + from app.routers.external_api_logs import list_external_api_logs + + now = datetime.now(timezone.utc) + for i in range(3): + session.add(ExternalApiLog( + direction="upstream" if i < 2 else "website", + target_type="upstream" if i < 2 else "website", + target_id=i + 1, target_name=f"tgt-{i}", + method="GET", path=f"/path/{i}", url_host="h.local", + status_code=200 if i < 2 else 500, success=i < 2, + duration_ms=100 + i, created_at=now, + )) + session.commit() + + _empty = None # sentinel for "no filter" + + # Filter by direction + result = list_external_api_logs( + direction="website", target_type=_empty, target_id=_empty, + success=_empty, status_code=_empty, path=_empty, + limit=100, offset=0, db=session, _=None, + ) + assert len(result) == 1 and result[0].direction == "website" + + # Filter by success + result = list_external_api_logs( + direction=_empty, target_type=_empty, target_id=_empty, + success=False, status_code=_empty, path=_empty, + limit=100, offset=0, db=session, _=None, + ) + assert len(result) == 1 and result[0].success is False + + # Filter by status_code + result = list_external_api_logs( + direction=_empty, target_type=_empty, target_id=_empty, + success=_empty, status_code=500, path=_empty, + limit=100, offset=0, db=session, _=None, + ) + assert len(result) == 1 and result[0].status_code == 500 + + # Filter by path + result = list_external_api_logs( + direction=_empty, target_type=_empty, target_id=_empty, + success=_empty, status_code=_empty, path="/path/0", + limit=100, offset=0, db=session, _=None, + ) + assert len(result) == 1 and "/path/0" in result[0].path + + # Filter by target_id + result = list_external_api_logs( + direction=_empty, target_type=_empty, target_id=2, + success=_empty, status_code=_empty, path=_empty, + limit=100, offset=0, db=session, _=None, + ) + assert len(result) == 1 and result[0].target_id == 2 + + +def test_route_list_pagination(session): + """list_external_api_logs respects limit/offset.""" + from app.routers.external_api_logs import list_external_api_logs + + now = datetime.now(timezone.utc) + for i in range(10): + session.add(ExternalApiLog( + direction="upstream", target_type="upstream", + method="GET", path=f"/p/{i}", url_host="h", + status_code=200, success=True, duration_ms=i, created_at=now, + )) + session.commit() + + _e = None + + assert len(list_external_api_logs( + direction=_e, target_type=_e, target_id=_e, + success=_e, status_code=_e, path=_e, + limit=3, offset=0, db=session, _=None, + )) == 3 + assert len(list_external_api_logs( + direction=_e, target_type=_e, target_id=_e, + success=_e, status_code=_e, path=_e, + limit=3, offset=3, db=session, _=None, + )) == 3 + assert len(list_external_api_logs( + direction=_e, target_type=_e, target_id=_e, + success=_e, status_code=_e, path=_e, + limit=3, offset=20, db=session, _=None, + )) == 0 + + +def test_route_summary_hours_window(session): + """get_external_api_log_summary respects hours parameter.""" + from app.routers.external_api_logs import get_external_api_log_summary + + now = datetime.now(timezone.utc) + # Recent (30 minutes ago — well within 1-hour window) + session.add(ExternalApiLog(direction="upstream", target_type="upstream", + method="GET", path="/recent", url_host="h", + status_code=200, success=True, duration_ms=50, + created_at=now - timedelta(minutes=30))) + # Old (48 hours ago — outside 1-hour window) + session.add(ExternalApiLog(direction="upstream", target_type="upstream", + method="GET", path="/old", url_host="h", + status_code=200, success=True, duration_ms=50, + created_at=now - timedelta(hours=48))) + session.commit() + + # hours=1 → only recent + result = get_external_api_log_summary( + slow_ms=None, hours=1, limit=50, db=session, _=None, + ) + assert len(result) == 1 and result[0].path == "/recent" + + # hours=72 → both + result = get_external_api_log_summary( + slow_ms=None, hours=72, limit=50, db=session, _=None, + ) + assert len(result) == 2 + + +def test_route_summary_limit(session): + """get_external_api_log_summary respects limit parameter.""" + from app.routers.external_api_logs import get_external_api_log_summary + + now = datetime.now(timezone.utc) + for i in range(10): + session.add(ExternalApiLog(direction="upstream", target_type="upstream", + method="GET", path=f"/path-{i}", url_host="h", + status_code=200, success=True, duration_ms=50 + i, created_at=now)) + session.commit() + + result = get_external_api_log_summary( + slow_ms=None, hours=24, limit=3, db=session, _=None, + ) + assert len(result) == 3 + + +def test_route_summary_slow_threshold(session): + """get_external_api_log_summary respects slow_ms parameter.""" + from app.routers.external_api_logs import get_external_api_log_summary + + now = datetime.now(timezone.utc) + session.add(ExternalApiLog(direction="upstream", target_type="upstream", + method="GET", path="/fast", url_host="h", + status_code=200, success=True, duration_ms=100, created_at=now)) + session.add(ExternalApiLog(direction="upstream", target_type="upstream", + method="GET", path="/fast2", url_host="h", + status_code=200, success=True, duration_ms=200, created_at=now)) + session.add(ExternalApiLog(direction="upstream", target_type="upstream", + method="GET", path="/slow", url_host="h", + status_code=200, success=True, duration_ms=5000, created_at=now)) + session.commit() + + result = get_external_api_log_summary( + slow_ms=1000, hours=24, limit=50, db=session, _=None, + ) + slow_paths = [r for r in result if r.slow_count > 0] + assert len(slow_paths) == 1 + assert slow_paths[0].path == "/slow" diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index d5125ee..72fe2aa 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -462,6 +462,64 @@ export const customPagesApi = { delete: (id: number) => api.delete(`/api/custom-pages/${id}`), } +// ——— External API Logs ——— +export interface ExternalApiLogData { + id: number + direction: string + target_type: string + target_id: number | null + target_name: string | null + method: string + path: string + url_host: string + status_code: number | null + success: boolean + duration_ms: number + error_type: string | null + error_message: string | null + created_at: string +} + +export interface ExternalApiLogSummaryItem { + direction: string + target_type: string + target_id: number | null + target_name: string | null + method: string + path: string + call_count: number + success_count: number + fail_count: number + avg_duration_ms: number + max_duration_ms: number + slow_count: number +} + +export const externalApiLogsApi = { + list: (params?: { + direction?: string + target_type?: string + target_id?: number + method?: string + success?: boolean + status_code?: number + path?: string + limit?: number + offset?: number + }) => api.get('/api/external-api-logs', { params }), + count: (params?: { + direction?: string + target_type?: string + target_id?: number + method?: string + success?: boolean + status_code?: number + path?: string + }) => api.get<{ total: number }>('/api/external-api-logs/count', { params }), + summary: (params?: { slow_ms?: number }) => + api.get('/api/external-api-logs/summary', { params }), +} + // ——— Auth Capture ——— export interface BrowserImportSession { session_id: string diff --git a/frontend/src/components/AppLayout.vue b/frontend/src/components/AppLayout.vue index 628b096..8eee86d 100644 --- a/frontend/src/components/AppLayout.vue +++ b/frontend/src/components/AppLayout.vue @@ -47,6 +47,13 @@ 响应记录、筛选、追踪异常 + + + + 接口日志 + 外部请求耗时、慢接口汇总 + + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index c0d6242..295d1ea 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -19,6 +19,7 @@ const router = createRouter({ { path: 'websites', component: () => import('@/views/Websites.vue') }, { path: 'webhooks', component: () => import('@/views/Webhooks.vue') }, { path: 'logs', component: () => import('@/views/NotificationLogs.vue') }, + { path: 'external-api-logs', component: () => import('@/views/ExternalApiLogs.vue') }, { path: 'custom-pages', component: () => import('@/views/CustomPages.vue') }, { path: 'page/:id', redirect: '/custom-pages' }, ], diff --git a/frontend/src/views/ExternalApiLogs.vue b/frontend/src/views/ExternalApiLogs.vue new file mode 100644 index 0000000..afe40fa --- /dev/null +++ b/frontend/src/views/ExternalApiLogs.vue @@ -0,0 +1,1241 @@ + + + + +