feat: 核心模块增强 — 加密 + 运行时配置 + 日志缓冲
- crypto.py: API Key 加密/解密工具 - runtime_config.py: 运行时动态配置管理 - log_buffer.py: 内存日志缓冲区 - config.py: 新增加密配置项 - http_client.py: 增强重试和错误处理
This commit is contained in:
@@ -0,0 +1,319 @@
|
||||
"""后台管理路由:数据源配置的查看、修改与连通性测试。
|
||||
|
||||
所有接口需管理员鉴权(require_admin)。配置项白名单见
|
||||
src/core/runtime_config.py SETTING_DEFS,之外的 key 一律拒绝。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from datetime import date, datetime, 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, 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},
|
||||
)
|
||||
Reference in New Issue
Block a user