feat: 核心模块增强 — 加密 + 运行时配置 + 日志缓冲

- crypto.py: API Key 加密/解密工具
- runtime_config.py: 运行时动态配置管理
- log_buffer.py: 内存日志缓冲区
- config.py: 新增加密配置项
- http_client.py: 增强重试和错误处理
This commit is contained in:
shangfangjian
2026-09-19 11:58:03 +08:00
parent b3e2c52b49
commit 786f10aa11
57 changed files with 3178 additions and 488 deletions
+319
View File
@@ -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},
)