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},
)
+136
View File
@@ -0,0 +1,136 @@
"""管理后台认证路由:密码登录 → HttpOnly Cookie 会话;支持在线修改密码。
管理员密码以 scrypt 哈希存于数据库(.env 明文仅作初始值,启动时自动迁移为哈希)。
修改密码会改变会话签名密钥,所有已登录会话随之失效,需重新登录。
"""
from __future__ import annotations
import logging
import secrets
import time
from collections import defaultdict, deque
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import BaseModel
from src.api.deps import (
SESSION_COOKIE,
auth_configured,
create_session_token,
get_session_secret,
require_admin,
verify_session_token,
)
from src.core import crypto
from src.core.config import settings
from src.core.runtime_config import (
get_admin_password_hash,
get_setting_origin,
set_admin_password_hash,
verify_admin_password,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
# 简易防爆破:10 分钟窗口内同一 IP 连续失败 5 次即锁定 10 分钟(内存态,重启清零)
_MAX_FAILS = 5
_WINDOW_SECONDS = 600
_fail_times: dict[str, deque[float]] = defaultdict(deque)
# 新密码强度要求
_MIN_PASSWORD_LEN = 8
_MAX_PASSWORD_LEN = 128
class LoginIn(BaseModel):
password: str
class PasswordChangeIn(BaseModel):
current_password: str
new_password: str
def _client_ip(request: Request) -> str:
return request.client.host if request.client else "unknown"
def _is_locked(ip: str) -> bool:
dq = _fail_times.get(ip)
if not dq:
return False
now = time.time()
while dq and now - dq[0] > _WINDOW_SECONDS:
dq.popleft()
return len(dq) >= _MAX_FAILS
@router.post("/login")
async def login(body: LoginIn, request: Request, response: Response):
ip = _client_ip(request)
if not (await get_admin_password_hash() or settings.ADMIN_PASSWORD):
raise HTTPException(status_code=503, detail="服务器未配置管理员密码,登录不可用")
if _is_locked(ip):
logger.warning("管理员登录尝试过于频繁 (ip=%s)", ip)
raise HTTPException(status_code=429, detail="失败次数过多,请 10 分钟后再试")
if not await verify_admin_password(body.password):
_fail_times[ip].append(time.time())
logger.warning("管理员登录失败 (ip=%s)", ip)
raise HTTPException(status_code=401, detail="密码错误")
_fail_times.pop(ip, None)
response.set_cookie(
key=SESSION_COOKIE,
value=create_session_token(await get_session_secret()),
max_age=settings.ADMIN_SESSION_TTL_HOURS * 3600,
httponly=True,
samesite="lax",
path="/",
)
logger.info("管理员登录成功 (ip=%s)", ip)
return {"ok": True, "expires_in_hours": settings.ADMIN_SESSION_TTL_HOURS}
@router.post("/logout")
async def logout(response: Response):
response.delete_cookie(key=SESSION_COOKIE, path="/")
return {"ok": True}
@router.get("/me")
async def me(request: Request):
"""前端登录门禁探测。未启用鉴权时视为已登录(本地开发模式)。"""
token = request.cookies.get(SESSION_COOKIE)
authenticated = not await auth_configured() or bool(
token and verify_session_token(token, await get_session_secret())
)
has_hash = bool(await get_admin_password_hash())
return {
"authenticated": authenticated,
"enabled": await auth_configured(),
"password_origin": "db" if has_hash else ("env" if settings.ADMIN_PASSWORD else "none"),
}
@router.post("/change-password", dependencies=[Depends(require_admin)])
async def change_password(body: PasswordChangeIn, request: Request, response: Response):
"""修改管理员密码:验证当前密码 → 写运行时覆盖 → 清除会话(全端登出)。"""
if not await auth_configured():
raise HTTPException(status_code=503, detail="服务器未配置管理员密码,无法修改")
if not await verify_admin_password(body.current_password):
logger.warning("修改密码失败:当前密码错误 (ip=%s)", _client_ip(request))
raise HTTPException(status_code=401, detail="当前密码错误")
new = body.new_password
if not (_MIN_PASSWORD_LEN <= len(new) <= _MAX_PASSWORD_LEN):
raise HTTPException(status_code=400, detail=f"新密码长度需在 {_MIN_PASSWORD_LEN}-{_MAX_PASSWORD_LEN} 位之间")
if await verify_admin_password(new):
raise HTTPException(status_code=400, detail="新密码不能与当前密码相同")
await set_admin_password_hash(crypto.hash_password(new))
# 密码即会话签名密钥,修改后所有旧会话失效;主动清除当前 Cookie 要求重新登录
response.delete_cookie(key=SESSION_COOKIE, path="/")
logger.info("管理员密码已修改 (ip=%s),所有会话已失效", _client_ip(request))
return {"ok": True, "message": "密码已修改,请用新密码重新登录"}
+4 -2
View File
@@ -6,7 +6,7 @@ import logging
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from src.api.deps import require_admin_key
from src.api.deps import require_admin
from src.llm.backtest import run_backtest
logger = logging.getLogger(__name__)
@@ -23,7 +23,7 @@ class BacktestRequest(BaseModel):
model: str | None = Field(None, description="指定模型 (空=默认)")
@router.post("/backtest", dependencies=[Depends(require_admin_key)])
@router.post("/backtest", dependencies=[Depends(require_admin)])
async def backtest(req: BacktestRequest):
"""对历史比赛运行回测。
@@ -64,6 +64,8 @@ async def backtest(req: BacktestRequest):
"league_code": r.league_code,
"home_team": r.home_team,
"away_team": r.away_team,
"home_team_zh": r.home_team_zh,
"away_team_zh": r.away_team_zh,
"match_date": r.match_date,
"actual_score": f"{r.actual_home}-{r.actual_away}",
"actual_1x2": r.actual_1x2,
+3 -3
View File
@@ -5,7 +5,7 @@ import logging
from fastapi import APIRouter, Depends, HTTPException
from src.api.deps import require_admin_key
from src.api.deps import require_admin
from src.api.schemas import EvalSummaryOut, SettleRequest
from src.db.base import AsyncSession, get_db, get_db_read
from src.llm.eval import get_eval_summary, settle_prediction
@@ -15,7 +15,7 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1", tags=["eval"])
@router.post("/eval/settle", dependencies=[Depends(require_admin_key)])
@router.post("/eval/settle", dependencies=[Depends(require_admin)])
async def settle(req: SettleRequest, db: AsyncSession = Depends(get_db)):
"""回填实际结果。"""
try:
@@ -29,7 +29,7 @@ async def settle(req: SettleRequest, db: AsyncSession = Depends(get_db)):
raise HTTPException(500, "回填失败,请查看服务器日志")
@router.get("/eval/summary", response_model=EvalSummaryOut)
@router.get("/eval/summary", response_model=EvalSummaryOut, dependencies=[Depends(require_admin)])
async def eval_summary():
"""提供商/模型准确率对比。"""
return await get_eval_summary()
+91 -27
View File
@@ -1,12 +1,14 @@
"""采集路由。"""
from __future__ import annotations
import asyncio
import logging
from fastapi import APIRouter, Depends, HTTPException
from src.api.deps import require_admin_key
from src.api.deps import require_admin
from src.api.schemas import IngestBzzoiroRequest, IngestResponse, IngestUnderstatRequest, IngestInjuriesRequest, IngestSimpleResponse
from src.data.config import BZZOIRO_LEAGUE_IDS, FDCO_TO_UNDERSTAT
from src.data.sources import get_source
from src.data.injuries import ingest_injuries
from src.db.unit_of_work import get_uow
@@ -15,46 +17,108 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1", tags=["ingest"])
# 后台采集任务注册表:持强引用防止被 GC
_background_tasks: set[asyncio.Task] = set()
@router.post("/ingest/bzzoiro", response_model=IngestResponse, dependencies=[Depends(require_admin_key)])
def _spawn(coro) -> None:
"""启动后台采集任务;异常已在任务内记录到系统日志。"""
task = asyncio.create_task(coro)
_background_tasks.add(task)
task.add_done_callback(_background_tasks.discard)
@router.post("/ingest/bzzoiro", dependencies=[Depends(require_admin)])
async def ingest_bzzoiro_route(req: IngestBzzoiroRequest):
"""触发 bzzoiro 采集。"""
source = get_source("bzzoiro")
# 未指定联赛 = 采集全部已知联赛;未指定状态 = 已完赛 + 未开赛都采集
leagues = req.leagues or list(BZZOIRO_LEAGUE_IDS.keys())
statuses = [req.status] if req.status else ["finished", "scheduled"]
_spawn(_run_bzzoiro(leagues, req.date_from, req.date_to, statuses))
return {
"ok": True,
"message": f"采集任务已启动(后台执行,状态: {', '.join(statuses)}),请在「系统日志」查看进度与结果",
}
async def _run_bzzoiro(leagues: list[str], date_from: str | None, date_to: str | None, statuses: list[str]) -> None:
"""后台执行 bzzoiro 采集:上游限速时单次可能耗时数分钟,必须脱离请求生命周期。"""
try:
source = get_source("bzzoiro")
merged: dict = {"leagues": {}, "total_inserted": 0, "total_updated": 0, "errors": []}
async with get_uow() as session:
result = await source.ingest(
session,
leagues=req.leagues,
date_from=req.date_from,
date_to=req.date_to,
status=req.status,
)
return IngestResponse(**result)
except Exception as e:
logger.exception("bzzoiro ingest failed")
raise HTTPException(500, "数据采集失败,请查看服务器日志")
for st in statuses:
r = await source.ingest(
session,
leagues=leagues,
date_from=date_from,
date_to=date_to,
status=st,
)
merged["total_inserted"] += r.get("total_inserted", 0)
merged["total_updated"] += r.get("total_updated", 0)
merged["errors"].extend(r.get("errors", []))
for code, stat in r.get("leagues", {}).items():
acc = merged["leagues"].setdefault(code, {"inserted": 0, "updated": 0, "errors": []})
acc["inserted"] += stat.get("inserted", 0)
acc["updated"] += stat.get("updated", 0)
acc["errors"].extend(stat.get("errors", []))
league_errors = {c: stat["errors"] for c, stat in merged["leagues"].items() if stat.get("errors")}
logger.info(
"bzzoiro 采集完成: 新增 %d, 更新 %d, 联赛 %d 个, 状态 %s",
merged["total_inserted"], merged["total_updated"], len(merged["leagues"]), statuses,
)
if league_errors:
sample = {c: errs[:1] for c, errs in list(league_errors.items())[:3]}
logger.warning("bzzoiro 部分联赛存在错误: %s", sample)
if merged["errors"]:
logger.warning("bzzoiro 采集错误 %d 条: %s", len(merged["errors"]), merged["errors"][:3])
except Exception:
logger.exception("bzzoiro 采集任务失败")
@router.post("/ingest/understat", response_model=IngestSimpleResponse, dependencies=[Depends(require_admin_key)])
@router.post("/ingest/understat", dependencies=[Depends(require_admin)])
async def ingest_understat_route(req: IngestUnderstatRequest):
"""触发 understat xG 回填。"""
source = get_source("understat")
leagues_to_run = [req.league] if req.league else list(FDCO_TO_UNDERSTAT.keys())
_spawn(_run_understat(leagues_to_run, req.season))
return {"ok": True, "message": "xG 回填任务已启动(后台执行),请在「系统日志」查看结果"}
async def _run_understat(leagues_to_run: list[str], season: int) -> None:
try:
source = get_source("understat")
merged: dict = {"count": 0, "updated": 0, "skipped": 0, "unmatched": 0, "errors": []}
async with get_uow() as session:
result = await source.ingest(session, league=req.league, season=req.season)
return IngestSimpleResponse(**result)
except Exception as e:
logger.exception("understat ingest failed")
raise HTTPException(500, "xG 回填失败,请查看服务器日志")
for league in leagues_to_run:
r = await source.ingest(session, league=league, season=season)
for k in ("count", "updated", "skipped", "unmatched"):
merged[k] += r.get(k, 0)
merged["errors"].extend(r.get("errors", []))
logger.info(
"understat 回填完成: 联赛 %d 个, 更新 %d, 未匹配 %d, 错误 %d",
len(leagues_to_run), merged["updated"], merged["unmatched"], len(merged["errors"]),
)
except Exception:
logger.exception("understat 回填任务失败")
@router.post("/ingest/injuries", response_model=IngestSimpleResponse, dependencies=[Depends(require_admin_key)])
@router.post("/ingest/injuries", dependencies=[Depends(require_admin)])
async def ingest_injuries_route(req: IngestInjuriesRequest):
"""触发伤停采集。"""
_spawn(_run_injuries(req.date))
return {"ok": True, "message": "伤停采集任务已启动(后台执行),请在「系统日志」查看结果"}
async def _run_injuries(date: str | None) -> None:
try:
async with get_uow() as session:
result = await ingest_injuries(session, date=req.date)
return IngestSimpleResponse(**result)
except Exception as e:
logger.exception("injuries ingest failed")
raise HTTPException(500, "伤停采集失败,请查看服务器日志")
result = await ingest_injuries(session, date=date)
logger.info(
"injuries 采集完成: 新增 %d, 更新 %d, 错误 %d",
result.get("count", 0), result.get("updated", 0), len(result.get("errors", [])),
)
if result.get("errors"):
logger.warning("injuries 采集错误: %s", result["errors"][:3])
except Exception:
logger.exception("injuries 采集任务失败")
+8 -2
View File
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from src.api.deps import require_admin
from src.api.schemas import MatchListOut, MatchOut
from src.db.base import AsyncSession, get_db_read
from src.db.models import League, Match
@@ -14,7 +15,7 @@ from src.db.models import League, Match
router = APIRouter(prefix="/api/v1", tags=["data"])
@router.get("/leagues", response_model=list[dict])
@router.get("/leagues", response_model=list[dict], dependencies=[Depends(require_admin)])
async def list_leagues(db: AsyncSession = Depends(get_db_read)):
stmt = select(League).order_by(League.name)
result = await db.execute(stmt)
@@ -64,7 +65,12 @@ async def list_matches(
raise HTTPException(400, "date 格式应为 YYYY-MM-DD")
q = q.where(Match.match_date >= d, Match.match_date < d + timedelta(days=1))
rows = (await db.execute(q.order_by(Match.match_date.desc(), Match.id.desc()).limit(limit + 1))).scalars().all()
# 未开赛按日期正序(最近的排最前,便于预测);其余按日期倒序(最新赛果在前)
if status == "scheduled":
order = (Match.match_date.asc(), Match.id.asc())
else:
order = (Match.match_date.desc(), Match.id.desc())
rows = (await db.execute(q.order_by(*order).limit(limit + 1))).scalars().all()
has_more = len(rows) > limit
rows = rows[:limit]
+19 -3
View File
@@ -7,9 +7,10 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from src.api.deps import require_admin
from src.api.schemas import PredictOut, PredictRequest, PredictionOut
from src.db.base import AsyncSession, get_db, get_db_read
from src.db.models import Prediction
from src.db.models import Match, Prediction
from src.llm.predict import predict_match, PredictResult
logger = logging.getLogger(__name__)
@@ -20,6 +21,12 @@ router = APIRouter(prefix="/api/v1", tags=["predict"])
@router.post("/predict", response_model=PredictOut)
async def predict(req: PredictRequest, db: AsyncSession = Depends(get_db)):
"""对一场比赛调 LLM 预测。mode=multi(默认,5专家+终裁)或 single。"""
# 已完赛比赛不再支持预测(回测走服务层直调,不受此限)
match = await db.get(Match, req.match_id)
if match is None:
raise HTTPException(404, "match not found")
if match.match_status == "finished":
raise HTTPException(400, "该比赛已完赛,不再支持预测")
try:
result = await predict_match(
req.match_id,
@@ -28,6 +35,9 @@ async def predict(req: PredictRequest, db: AsyncSession = Depends(get_db)):
mode=req.mode,
)
except ValueError as e:
msg = str(e)
if "已结算" in msg:
raise HTTPException(409, msg)
logger.warning("predict validation error: %s", e)
raise HTTPException(404, "比赛不存在")
except RuntimeError as e:
@@ -46,6 +56,8 @@ async def predict(req: PredictRequest, db: AsyncSession = Depends(get_db)):
mode=getattr(result, "mode", "single"),
pred_home_goals=result.pred_home_goals,
pred_away_goals=result.pred_away_goals,
alt_pred_home_goals=result.alt_pred_home_goals,
alt_pred_away_goals=result.alt_pred_away_goals,
pred_1x2=result.pred_1x2,
subjective_confidence=result.subjective_confidence,
reasoning=result.reasoning,
@@ -54,9 +66,13 @@ async def predict(req: PredictRequest, db: AsyncSession = Depends(get_db)):
context=result.context,
latency_ms=result.latency_ms,
)
logger.info(
"预测完成 match=%s mode=%s pred=%s:%s (%s)",
req.match_id, req.mode, result.pred_home_goals, result.pred_away_goals, result.pred_1x2,
)
@router.get("/predictions", response_model=list[PredictionOut])
@router.get("/predictions", response_model=list[PredictionOut], dependencies=[Depends(require_admin)])
async def list_predictions(
match_id: int | None = None,
limit: int = Query(50, ge=1, le=200),
@@ -90,7 +106,7 @@ async def list_predictions(
]
@router.get("/predictions/{prediction_id}", response_model=PredictionOut)
@router.get("/predictions/{prediction_id}", response_model=PredictionOut, dependencies=[Depends(require_admin)])
async def get_prediction(prediction_id: int, db: AsyncSession = Depends(get_db_read)):
p = await db.get(Prediction, prediction_id)
if p is None: