440 lines
16 KiB
Python
440 lines
16 KiB
Python
"""后台管理路由:数据源配置的查看、修改与连通性测试。
|
|
|
|
所有接口需管理员鉴权(require_admin)。配置项白名单见
|
|
src/core/runtime_config.py SETTING_DEFS,之外的 key 一律拒绝。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import time
|
|
from datetime import date, datetime, timedelta, timezone
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import func, select
|
|
import httpx
|
|
|
|
from src.api.deps import require_admin
|
|
from src.core.config import settings
|
|
from src.core.http_client import get_client
|
|
from src.core.log_buffer import get_entries
|
|
from src.core.runtime_config import (
|
|
AGENT_META,
|
|
SETTING_DEFS,
|
|
clear_runtime_value,
|
|
get_runtime_value,
|
|
get_setting_origin,
|
|
mask_value,
|
|
set_runtime_value,
|
|
)
|
|
from src.db.base import AsyncSession, get_db_read
|
|
from src.db.models import Injury, Match, MatchStats
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/api/v1/admin", tags=["admin"], dependencies=[Depends(require_admin)])
|
|
|
|
# ── 数据源元数据 ────────────────────────────────────────────────
|
|
|
|
_SOURCES: list[dict] = [
|
|
{
|
|
"name": "bzzoiro",
|
|
"label": "Bzzoiro",
|
|
"description": "历史赛程与比分数据,覆盖全球主要联赛",
|
|
"setting_keys": ["BZZOIRO_KEY", "BZZOIRO_BASE"],
|
|
},
|
|
{
|
|
"name": "understat",
|
|
"label": "Understat",
|
|
"description": "xG(预期进球)进阶数据,无需 API Key,网页抓取",
|
|
"setting_keys": [],
|
|
},
|
|
{
|
|
"name": "injuries",
|
|
"label": "Injuries (API-Football)",
|
|
"description": "球员伤停信息,用于预测时考虑阵容完整性",
|
|
"setting_keys": ["API_FOOTBALL_KEY"],
|
|
},
|
|
]
|
|
|
|
|
|
class SettingUpdateIn(BaseModel):
|
|
value: str
|
|
|
|
|
|
async def _last_ingestion(db: AsyncSession, source: str) -> datetime | None:
|
|
"""各源最近一次采集时间(取自数据血缘字段,无记录返回 None)。"""
|
|
if source == "injuries":
|
|
return (await db.execute(select(func.max(Injury.retrieved_at)))).scalar()
|
|
return (
|
|
await db.execute(
|
|
select(func.max(MatchStats.retrieved_at)).where(MatchStats.source == source)
|
|
)
|
|
).scalar()
|
|
|
|
|
|
@router.get("/datasources")
|
|
async def list_datasources(db: AsyncSession = Depends(get_db_read)):
|
|
"""数据源列表:各配置项的脱敏值、来源(db/env/none)与最近采集时间。"""
|
|
result = []
|
|
for src in _SOURCES:
|
|
settings_out = []
|
|
for key in src["setting_keys"]:
|
|
origin, value = await get_setting_origin(key)
|
|
defn = SETTING_DEFS[key]
|
|
settings_out.append(
|
|
{
|
|
"key": key,
|
|
"label": defn.label,
|
|
"description": defn.description,
|
|
"sensitive": defn.sensitive,
|
|
"configured": origin != "none",
|
|
"masked": mask_value(value, defn.sensitive),
|
|
"origin": origin,
|
|
}
|
|
)
|
|
key_configured = all(s["configured"] for s in settings_out) if settings_out else True
|
|
last = await _last_ingestion(db, src["name"])
|
|
result.append(
|
|
{
|
|
"name": src["name"],
|
|
"label": src["label"],
|
|
"description": src["description"],
|
|
"key_configured": key_configured,
|
|
"last_ingestion": last.isoformat() if last else None,
|
|
"settings": settings_out,
|
|
}
|
|
)
|
|
return result
|
|
|
|
|
|
@router.get("/settings")
|
|
async def list_settings():
|
|
"""全部可配置项(脱敏),供后台各配置页渲染。"""
|
|
out = []
|
|
for key, defn in SETTING_DEFS.items():
|
|
origin, value = await get_setting_origin(key)
|
|
out.append(
|
|
{
|
|
"key": key,
|
|
"label": defn.label,
|
|
"description": defn.description,
|
|
"sensitive": defn.sensitive,
|
|
"configured": origin != "none",
|
|
"masked": mask_value(value, defn.sensitive),
|
|
"origin": origin,
|
|
}
|
|
)
|
|
return out
|
|
|
|
|
|
# ── LLM 可用模型检测 ────────────────────────────────────────────
|
|
|
|
|
|
@router.get("/logs")
|
|
async def read_logs(
|
|
level: str | None = Query(None, description="最低级别: DEBUG/INFO/WARNING/ERROR"),
|
|
keyword: str | None = Query(None, description="消息或 logger 关键字"),
|
|
limit: int = Query(200, ge=1, le=1000),
|
|
):
|
|
"""查询应用运行日志(内存环形缓冲,最新在前;进程重启后清零)。"""
|
|
entries = get_entries(level, keyword, limit)
|
|
return {"entries": entries, "count": len(entries)}
|
|
|
|
|
|
@router.get("/llm/agents")
|
|
async def list_llm_agents():
|
|
"""各专家/终裁的独立 LLM 配置状态(含当前生效模型的解析结果)。"""
|
|
out = []
|
|
for agent in AGENT_META:
|
|
aid = agent["id"].upper()
|
|
pfx = f"AGENT_{aid}_"
|
|
fields = {}
|
|
for suffix in ("MODEL", "BASE_URL", "API_KEY"):
|
|
origin, value = await get_setting_origin(f"{pfx}{suffix}")
|
|
defn = SETTING_DEFS[f"{pfx}{suffix}"]
|
|
fields[suffix.lower()] = {
|
|
"configured": origin != "none",
|
|
"masked": mask_value(value, defn.sensitive),
|
|
"origin": origin,
|
|
}
|
|
# 生效模型 = 覆盖 → 层级默认(专家/终裁 env) → 全局 LLM_MODEL
|
|
tier_default = (
|
|
settings.LLM_AGGREGATOR_MODEL if agent["id"] == "aggregator" else settings.LLM_SPECIALIST_MODEL
|
|
)
|
|
effective_model = (
|
|
fields["model"]["masked"]
|
|
if fields["model"]["configured"]
|
|
else (tier_default or await get_runtime_value("LLM_MODEL"))
|
|
)
|
|
out.append(
|
|
{
|
|
"id": agent["id"],
|
|
"label": agent["label"],
|
|
"fields": fields,
|
|
"effective_model": effective_model,
|
|
}
|
|
)
|
|
return out
|
|
|
|
|
|
@router.get("/llm/models")
|
|
async def list_llm_models():
|
|
"""探测当前 LLM 服务可用的模型列表(OpenAI 兼容 GET /models)。
|
|
|
|
只读探测,不产生费用;配置缺失或服务不可达时返回 ok=false 与原因。
|
|
"""
|
|
base_url = (await get_runtime_value("LLM_BASE_URL")).rstrip("/")
|
|
api_key = await get_runtime_value("LLM_API_KEY")
|
|
if not base_url or not api_key:
|
|
return {"ok": False, "models": [], "detail": "LLM_BASE_URL 或 LLM_API_KEY 未配置"}
|
|
|
|
client = get_client()
|
|
start = time.monotonic()
|
|
try:
|
|
resp = await client.get(
|
|
f"{base_url}/models",
|
|
headers={"Authorization": f"Bearer {api_key}"},
|
|
timeout=httpx.Timeout(connect=10.0, read=20.0, write=10.0, pool=10.0),
|
|
)
|
|
except Exception as e:
|
|
return {
|
|
"ok": False,
|
|
"models": [],
|
|
"latency_ms": int((time.monotonic() - start) * 1000),
|
|
"detail": f"无法连接 LLM 服务: {e}",
|
|
}
|
|
|
|
latency = int((time.monotonic() - start) * 1000)
|
|
if resp.status_code in (401, 403):
|
|
return {"ok": False, "models": [], "latency_ms": latency, "detail": "密钥无效或无权限(HTTP 401/403)"}
|
|
if resp.status_code != 200:
|
|
return {"ok": False, "models": [], "latency_ms": latency, "detail": f"服务返回 HTTP {resp.status_code}"}
|
|
|
|
try:
|
|
data = resp.json()
|
|
except Exception:
|
|
return {"ok": False, "models": [], "latency_ms": latency, "detail": "响应不是合法 JSON"}
|
|
|
|
models: list[str] = []
|
|
items = data.get("data") if isinstance(data, dict) else None
|
|
if isinstance(items, list):
|
|
models = sorted(
|
|
str(m.get("id")) for m in items if isinstance(m, dict) and m.get("id")
|
|
)
|
|
if not models:
|
|
return {"ok": False, "models": [], "latency_ms": latency, "detail": "服务未返回模型列表"}
|
|
return {"ok": True, "models": models, "latency_ms": latency, "detail": f"共 {len(models)} 个可用模型"}
|
|
|
|
|
|
@router.put("/settings/{key}")
|
|
async def update_setting(key: str, body: SettingUpdateIn):
|
|
"""更新配置项(写入 app_settings 覆盖 .env)。传空值请改用 DELETE。"""
|
|
if key not in SETTING_DEFS:
|
|
raise HTTPException(404, f"不支持的配置项: {key}")
|
|
value = body.value.strip()
|
|
if not value:
|
|
raise HTTPException(400, "值不能为空;如需回落 .env 请调用清除接口")
|
|
await set_runtime_value(key, value)
|
|
defn = SETTING_DEFS[key]
|
|
return {"key": key, "masked": mask_value(value, defn.sensitive), "origin": "db"}
|
|
|
|
|
|
@router.delete("/settings/{key}")
|
|
async def clear_setting(key: str):
|
|
"""清除 DB 覆盖值,回落 .env 默认。"""
|
|
if key not in SETTING_DEFS:
|
|
raise HTTPException(404, f"不支持的配置项: {key}")
|
|
await clear_runtime_value(key)
|
|
origin, value = await get_setting_origin(key)
|
|
defn = SETTING_DEFS[key]
|
|
return {
|
|
"key": key,
|
|
"masked": mask_value(value, defn.sensitive),
|
|
"origin": origin,
|
|
}
|
|
|
|
|
|
# ── 连通性测试 ──────────────────────────────────────────────────
|
|
|
|
_TEST_TIMEOUT = 15
|
|
|
|
|
|
async def _probe(url: str, headers: dict | None = None, params: dict | None = None) -> dict:
|
|
"""单次 HTTP 探测,返回 (ok, status, latency_ms, detail)。不重试。"""
|
|
client = get_client()
|
|
start = time.monotonic()
|
|
try:
|
|
resp = await client.get(url, headers=headers, params=params, timeout=_TEST_TIMEOUT)
|
|
except Exception as e:
|
|
return {
|
|
"ok": False,
|
|
"status": None,
|
|
"latency_ms": int((time.monotonic() - start) * 1000),
|
|
"detail": f"无法连接: {e}",
|
|
}
|
|
latency = int((time.monotonic() - start) * 1000)
|
|
status = resp.status_code
|
|
if status == 200:
|
|
detail = "连接成功"
|
|
elif status in (401, 403):
|
|
detail = "服务可达,但密钥无效或无权限"
|
|
else:
|
|
detail = f"服务返回 HTTP {status}"
|
|
return {"ok": status == 200, "status": status, "latency_ms": latency, "detail": detail}
|
|
|
|
|
|
@router.post("/datasources/{name}/test")
|
|
async def test_datasource(name: str):
|
|
"""轻量连通性测试:真实请求上游一次,不触发任何入库。"""
|
|
src = next((s for s in _SOURCES if s["name"] == name), None)
|
|
if src is None:
|
|
raise HTTPException(404, f"未知数据源: {name}")
|
|
|
|
if name == "bzzoiro":
|
|
key = await get_runtime_value("BZZOIRO_KEY")
|
|
if not key:
|
|
return {"ok": False, "status": None, "latency_ms": 0, "detail": "BZZOIRO_KEY 未配置"}
|
|
base = (await get_runtime_value("BZZOIRO_BASE")).rstrip("/")
|
|
today = date.today().isoformat()
|
|
return await _probe(
|
|
f"{base}/events/",
|
|
headers={"Authorization": f"Token {key}", "Accept": "application/json"},
|
|
params={"date_from": today, "date_to": today},
|
|
)
|
|
|
|
if name == "understat":
|
|
return await _probe(
|
|
"https://understat.com/league/EPL/2025",
|
|
headers={"User-Agent": "Mozilla/5.0", "Accept": "text/html"},
|
|
)
|
|
|
|
# injuries (api-football)
|
|
api_key = await get_runtime_value("API_FOOTBALL_KEY")
|
|
if not api_key:
|
|
return {"ok": False, "status": None, "latency_ms": 0, "detail": "API_FOOTBALL_KEY 未配置"}
|
|
return await _probe(
|
|
"https://v3.football.api-sports.io/status",
|
|
headers={"x-apisports-key": api_key},
|
|
)
|
|
|
|
|
|
# ── 数据源健康/最近采集状态(只读,不触发采集) ──────────────────────
|
|
|
|
|
|
@router.get("/ingest/status")
|
|
async def ingest_status(db: AsyncSession = Depends(get_db_read)):
|
|
"""各数据源采集健康概览(只读,不触发任何采集)。
|
|
|
|
返回尽量可得的信息;基于现有表近似的数据会标明 approximation。
|
|
失败追踪目前依赖系统日志缓冲,无专用采集失败表。
|
|
"""
|
|
# ── bzzoiro: 落库目标是 matches 表,无专用采集时间戳 ──
|
|
# 近似:以 matches 表最大 match_date(已覆盖的最远比赛日) 与 created_at 作为参考
|
|
bzzoiro_key = await get_runtime_value("BZZOIRO_KEY")
|
|
bzzoiro_base = await get_runtime_value("BZZOIRO_BASE")
|
|
row = (
|
|
await db.execute(
|
|
select(
|
|
func.count().label("cnt"),
|
|
func.max(Match.match_date).label("latest_match_date"),
|
|
func.max(Match.created_at).label("latest_row_at"),
|
|
).where(Match.match_status == "finished")
|
|
)
|
|
).one()
|
|
bzzoiro = {
|
|
"name": "bzzoiro",
|
|
"label": "Bzzoiro",
|
|
"key_configured": bool(bzzoiro_key),
|
|
"base_url": (bzzoiro_base.rstrip("/") if bzzoiro_base else None) or settings.BZZOIRO_BASE,
|
|
"reachable": None, # 不主动探测
|
|
"last_success_at": row.latest_row_at.isoformat() if row.latest_row_at else None,
|
|
"latest_match_date": row.latest_match_date.isoformat() if row.latest_match_date else None,
|
|
"recent_count": row.cnt or 0,
|
|
"note": "approx:基于 matches.finished 表,last_success_at 为行写入时间而非精确采集完成时间",
|
|
"last_failure": _last_failure_log("bzzoiro"),
|
|
}
|
|
|
|
# ── understat: 落库到 match_stats(source=understat),有精确 retrieved_at ──
|
|
row = (
|
|
await db.execute(
|
|
select(
|
|
func.count().label("cnt"),
|
|
func.max(MatchStats.retrieved_at).label("latest_retrieved"),
|
|
).where(MatchStats.source == "understat")
|
|
)
|
|
).one()
|
|
understat = {
|
|
"name": "understat",
|
|
"label": "Understat",
|
|
"key_configured": True, # 无需 Key
|
|
"reachable": None,
|
|
"last_success_at": row.latest_retrieved.isoformat() if row.latest_retrieved else None,
|
|
"recent_count": row.cnt or 0,
|
|
"note": "基于 match_stats.source=understat 的 retrieved_at",
|
|
"last_failure": _last_failure_log("understat"),
|
|
}
|
|
|
|
# ── injuries: 落库到 injuries 表,有精确 retrieved_at;区分 Key/无数据/有数据 ──
|
|
api_key = await get_runtime_value("API_FOOTBALL_KEY")
|
|
row = (
|
|
await db.execute(
|
|
select(
|
|
func.count().label("cnt"),
|
|
func.max(Injury.retrieved_at).label("latest_retrieved"),
|
|
)
|
|
)
|
|
).one()
|
|
if not api_key:
|
|
injuries_status, injuries_note = "key_not_configured", "API_FOOTBALL_KEY 未配置"
|
|
elif not row.cnt:
|
|
injuries_status, injuries_note = "no_data", "本地无伤停数据,请先采集"
|
|
else:
|
|
injuries_status, injuries_note = "has_data", f"共 {row.cnt} 条伤停记录"
|
|
injuries = {
|
|
"name": "injuries",
|
|
"label": "Injuries (API-Football)",
|
|
"key_configured": bool(api_key),
|
|
"reachable": None,
|
|
"status": injuries_status,
|
|
"last_success_at": row.latest_retrieved.isoformat() if row.latest_retrieved else None,
|
|
"recent_count": row.cnt or 0,
|
|
"note": injuries_note,
|
|
"last_failure": _last_failure_log("injuries"),
|
|
}
|
|
|
|
return {"sources": [bzzoiro, understat, injuries]}
|
|
|
|
|
|
def _last_failure_log(source: str) -> dict | None:
|
|
"""从系统日志缓冲中查找某数据源的最近一次错误(仅作参考,非专用失败表)。"""
|
|
entries = get_entries(min_level="ERROR", keyword=source, limit=5)
|
|
if not entries:
|
|
return None
|
|
e = entries[0]
|
|
return {
|
|
"at": datetime.fromtimestamp(e["ts"]).isoformat(),
|
|
"logger": e["logger"],
|
|
"detail": e["message"][:200],
|
|
"note": "approx:来自内存日志缓冲,非专用采集失败表;进程重启后清零",
|
|
}
|
|
|
|
|
|
@router.get("/stats")
|
|
async def admin_stats(db: AsyncSession = Depends(get_db_read)):
|
|
"""管理区统计(只读):最近预测次数。轻量聚合,无 LLM 调用。"""
|
|
from sqlalchemy import func, text
|
|
from src.db.models import Prediction
|
|
day_ago = datetime.now(timezone.utc) - timedelta(days=1)
|
|
week_ago = datetime.now(timezone.utc) - timedelta(days=7)
|
|
r = (
|
|
await db.execute(
|
|
select(
|
|
func.count().label("total"),
|
|
func.count().filter(Prediction.created_at >= day_ago).label("last_24h"),
|
|
func.count().filter(Prediction.created_at >= week_ago).label("last_7d"),
|
|
)
|
|
)
|
|
).one()
|
|
return {"predictions": {"total": r.total, "last_24h": r.last_24h, "last_7d": r.last_7d}}
|