feat: 重构接口日志页面与接口监控看板,支持HTTP方法过滤和实时轮询

This commit is contained in:
liumangmang
2026-07-01 15:04:32 +08:00
parent 397a14c978
commit 8abfa4c5ef
19 changed files with 2463 additions and 37 deletions
+3
View File
@@ -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]:
+3 -1
View File
@@ -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)",
]
+2 -1
View File
@@ -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")
+1
View File
@@ -1 +1,2 @@
# models package
from app.models.external_api_log import ExternalApiLog
+28
View File
@@ -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")),
)
+161
View File
@@ -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
+8
View File
@@ -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)
+2
View File
@@ -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,
)
+37
View File
@@ -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
@@ -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()
+29
View File
@@ -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)
+61 -20
View File
@@ -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")
+64 -15
View File
@@ -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。"""
+6
View File
@@ -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