fix:批量修复了一些问题

This commit is contained in:
shangfangjian
2026-09-19 22:51:35 +08:00
parent 835d7217d0
commit 8e6ad5394e
44 changed files with 4921 additions and 395 deletions
+16 -3
View File
@@ -10,6 +10,8 @@ from fastapi.middleware.cors import CORSMiddleware
from src.core.config import settings
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
@@ -19,9 +21,11 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
ensure_admin_password_hashed,
migrate_plaintext_sensitive_settings,
)
from src.core.security_check import assert_security_on_startup
await init_db() # 验证连接,不建表
await migrate_plaintext_sensitive_settings() # 明文敏感配置 → 加密(幂等)
await ensure_admin_password_hashed() # .env 明文密码 → scrypt 哈希(幂等)
await assert_security_on_startup() # 启动安全校验(生产拒绝/开发警告)
yield
await close_client()
@@ -74,14 +78,23 @@ def create_app() -> FastAPI:
@app.get("/health/ready")
async def health_ready():
"""就绪检查: 验证数据库连接。"""
"""就绪检查:验证数据库连接。
数据库不可达时返回 HTTP 503,而非 200 + not_ready ——
这样 K8s/Compose 的 readinessProbe 才能正确判定「未就绪」并停止流量。
"""
from src.db.base import engine
from fastapi.responses import JSONResponse
try:
async with engine.begin() as conn:
await conn.run_sync(lambda conn: None)
return {"status": "ready"}
except Exception:
return {"status": "not_ready"}
except Exception as e:
logger.warning("就绪检查失败(数据库不可达): %s", e)
return JSONResponse(
status_code=503,
content={"status": "not_ready", "reason": "database_unreachable"},
)
return app
+6
View File
@@ -167,6 +167,12 @@ class _RateLimiter:
self._hits[key] = timestamps
return True
def remaining(self, key: str) -> int:
"""当前窗口内剩余可用次数。"""
now = time.time()
timestamps = [t for t in self._hits.get(key, []) if t > now - self.window_seconds]
return max(0, self.max_requests - len(timestamps))
# 全局限流实例: /api/v1/predict 每分钟 10 次
_predict_limiter = _RateLimiter(max_requests=10, window_seconds=60)
+122 -2
View File
@@ -7,7 +7,7 @@ from __future__ import annotations
import logging
import time
from datetime import date, datetime, timezone
from datetime import date, datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
@@ -28,7 +28,7 @@ from src.core.runtime_config import (
set_runtime_value,
)
from src.db.base import AsyncSession, get_db_read
from src.db.models import Injury, MatchStats
from src.db.models import Injury, Match, MatchStats
logger = logging.getLogger(__name__)
@@ -317,3 +317,123 @@ async def test_datasource(name: str):
"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}}
+9 -5
View File
@@ -28,13 +28,16 @@ async def backtest(req: BacktestRequest):
"""对历史比赛运行回测。
该接口会对每场已完赛比赛各发起一次 LLM 预测,成本高 —— 因此需要
`X-API-Key` 鉴权(见审查报告 P2-7)。
管理员鉴权(require_admin)。
对每场已完赛比赛:
1. 用比赛之前的数据构建上下文 (防未来信息泄漏)
2. 调 LLM 预测
3. 用实际比分回填
1. 用比赛之前的数据构建上下文 (防未来信息泄漏,cutoff=match_date-1天)
2. 调 LLM 预测(强制 use_cache=False,避免缓存命中导致反复 settle 同一行)
3. 用实际比分回填(settle)
4. 统计准确率 / RMSE / 校准度
限流:单请求上限 200 场(默认 20),避免一次打爆 LLM 额度。
回测写入 run_type='backtest',与实盘(live)互不覆盖(唯一键含 run_type)。
"""
try:
summary = await run_backtest(
@@ -53,10 +56,11 @@ async def backtest(req: BacktestRequest):
"summary": {
"total": summary.total,
"scored": summary.scored,
"success": summary.success,
"degraded": summary.degraded,
"accuracy_1x2": summary.accuracy_1x2,
"avg_score_rmse": summary.avg_score_rmse,
"avg_subjective_confidence": summary.avg_subjective_confidence,
"calibration": summary.calibration,
},
"results": [
{
+8 -2
View File
@@ -19,13 +19,19 @@ router = APIRouter(prefix="/api/v1", tags=["eval"])
async def settle(req: SettleRequest, db: AsyncSession = Depends(get_db)):
"""回填实际结果。
status 为 degraded/failed 的预测无法结算
status 为 degraded/failed 的预测无法结算(返回 400);
记录不存在返回 404。
"""
try:
pred = await settle_prediction(req.prediction_id, req.home_goals, req.away_goals)
return {"id": pred.id, "settled": pred.settled}
except ValueError as e:
logger.warning("settle failed: %s", e)
msg = str(e)
# degraded/failed 拒绝:明确的 400,而非与"未找到"混为一谈
if "无法结算" in msg:
logger.warning("settle rejected: %s", msg)
raise HTTPException(400, msg)
logger.warning("settle failed: %s", msg)
raise HTTPException(404, "预测记录不存在")
except Exception as e:
logger.exception("settle error")
+1
View File
@@ -73,6 +73,7 @@ async def _run_bzzoiro(leagues: list[str], date_from: str | None, date_to: str |
logger.warning("bzzoiro 部分联赛存在错误: %s", sample)
if merged["errors"]:
logger.warning("bzzoiro 采集错误 %d 条: %s", len(merged["errors"]), merged["errors"][:3])
logger.debug("bzzoiro 采集明细: leagues=%s", list(merged["leagues"].keys()))
except Exception:
logger.exception("bzzoiro 采集任务失败")
+108 -4
View File
@@ -4,13 +4,13 @@ from __future__ import annotations
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy import or_, select
from sqlalchemy.orm import selectinload
from src.api.deps import require_admin
from src.api.schemas import MatchListOut, MatchOut
from src.api.schemas import MatchListOut, MatchOut, PredictionOut
from src.db.base import AsyncSession, get_db_read
from src.db.models import League, Match
from src.db.models import League, Match, Prediction
router = APIRouter(prefix="/api/v1", tags=["data"])
@@ -114,12 +114,26 @@ async def list_matches(
async def get_match(match_id: int, db: AsyncSession = Depends(get_db_read)):
stmt = (
select(Match)
.options(selectinload(Match.league), selectinload(Match.home_team), selectinload(Match.away_team))
.options(
selectinload(Match.league),
selectinload(Match.home_team),
selectinload(Match.away_team),
selectinload(Match.stats),
)
.where(Match.id == match_id)
)
m = (await db.execute(stmt)).scalar_one_or_none()
if m is None:
raise HTTPException(404, "match not found")
# 最近预测(倒序,最多 5 条)——复用 PredictionOut 结构,只读,不触发 LLM
preds = (
await db.execute(
select(Prediction)
.where(Prediction.match_id == match_id)
.order_by(Prediction.created_at.desc())
.limit(5)
)
).scalars().all()
return MatchOut(
id=m.id,
league_code=m.league.code if m.league else None,
@@ -135,4 +149,94 @@ async def get_match(match_id: int, db: AsyncSession = Depends(get_db_read)):
match_stage=m.match_stage,
home_xg=m.stats.home_xg if m.stats else None,
away_xg=m.stats.away_xg if m.stats else None,
recent_predictions=[
PredictionOut(
id=p.id, match_id=p.match_id, provider=p.provider, model=p.model,
prompt_version=p.prompt_version, mode=p.mode or "single",
pred_home_goals=p.pred_home_goals, pred_away_goals=p.pred_away_goals,
alt_pred_home_goals=p.alt_pred_home_goals, alt_pred_away_goals=p.alt_pred_away_goals,
pred_1x2=p.pred_1x2, subjective_confidence=p.subjective_confidence,
reasoning=p.reasoning, status=p.status or "success",
agent_outputs=p.agent_outputs, agent_weights=p.agent_weights,
created_at=p.created_at, actual_home_goals=p.actual_home_goals,
actual_away_goals=p.actual_away_goals, settled=p.settled,
)
for p in preds
],
)
@router.get("/matches/{match_id}/context", dependencies=[Depends(require_admin)])
async def match_context(match_id: int, db: AsyncSession = Depends(get_db_read)):
"""比赛上下文(只读,不触发 LLM):双方近况 + 历史交锋。
全部基于现有数据聚合:
- recent_home / recent客队:该队最近 5 场已完赛(进球/结果)
- h2h:双方最近 5 次交手
若数据不足,对应列表为空(前端展示空态)。
"""
m = (
await db.execute(
select(Match)
.options(selectinload(Match.home_team), selectinload(Match.away_team))
.where(Match.id == match_id)
)
).scalar_one_or_none()
if m is None:
raise HTTPException(404, "match not found")
home_id = m.home_team_id
away_id = m.away_team_id
def _row_to_dict(row):
return {
"match_date": row.match_date.isoformat() if row.match_date else None,
"home_team": row.home_team.name_zh or row.home_team.name if row.home_team else None,
"away_team": row.away_team.name_zh or row.away_team.name if row.away_team else None,
"home_goals": row.home_goals,
"away_goals": row.away_goals,
}
# 主队近况(已完赛,含主/客场)
home_recent = (
await db.execute(
select(Match)
.where(Match.match_status == "finished", Match.home_team_id == home_id)
.order_by(Match.match_date.desc())
.limit(5)
.options(selectinload(Match.home_team), selectinload(Match.away_team))
)
).scalars().all()
# 客队近况
away_recent = (
await db.execute(
select(Match)
.where(Match.match_status == "finished", Match.away_team_id == away_id)
.order_by(Match.match_date.desc())
.limit(5)
.options(selectinload(Match.home_team), selectinload(Match.away_team))
)
).scalars().all()
# 历史交锋(双方已完赛)
h2h = (
await db.execute(
select(Match)
.where(
Match.match_status == "finished",
or_(
(Match.home_team_id == home_id) & (Match.away_team_id == away_id),
(Match.home_team_id == away_id) & (Match.away_team_id == home_id),
),
)
.order_by(Match.match_date.desc())
.limit(5)
.options(selectinload(Match.home_team), selectinload(Match.away_team))
)
).scalars().all()
return {
"home_recent": [_row_to_dict(r) for r in home_recent],
"away_recent": [_row_to_dict(r) for r in away_recent],
"h2h": [_row_to_dict(r) for r in h2h],
}
+71 -18
View File
@@ -42,7 +42,7 @@ async def predict(req: PredictRequest):
if m.match_status == "finished":
raise HTTPException(400, "该比赛已完赛,不再支持预测")
# 2. LLM 调用(不持有任何 DB 连接)
# 2. 预测调用(不持有任何 DB 连接)
try:
result = await predict_match(
req.match_id,
@@ -63,32 +63,77 @@ async def predict(req: PredictRequest):
logger.exception("predict unexpected error")
raise HTTPException(500, "预测失败,请查看服务器日志")
# baseline 模式:结果已是 dict,需独立落库(prediction_id)
if req.mode == "baseline":
prediction_id = await _persist_baseline(req.match_id, result)
else:
prediction_id = result.prediction_id
# 3. 结果映射(无 DB 访问)
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,
req.match_id, req.mode,
result.get("pred_home_goals") if isinstance(result, dict) else result.pred_home_goals,
result.get("pred_away_goals") if isinstance(result, dict) else result.pred_away_goals,
result.get("pred_1x2") if isinstance(result, dict) else result.pred_1x2,
)
result_dict = result if isinstance(result, dict) else None
return PredictOut(
prediction_id=result.prediction_id,
provider=result.provider,
model=result.model,
prompt_version=getattr(result, "prompt_version", None),
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,
agent_outputs=getattr(result, "agent_outputs", None),
agent_weights=getattr(result, "agent_weights", None),
context=result.context,
latency_ms=result.latency_ms,
prediction_id=prediction_id,
provider=result.get("provider") if result_dict else result.provider,
model=result.get("model") if result_dict else result.model,
prompt_version=result.get("prompt_version") if result_dict else getattr(result, "prompt_version", None),
mode=req.mode,
pred_home_goals=result.get("pred_home_goals") if result_dict else result.pred_home_goals,
pred_away_goals=result.get("pred_away_goals") if result_dict else result.pred_away_goals,
alt_pred_home_goals=result.get("alt_pred_home_goals") if result_dict else result.alt_pred_home_goals,
alt_pred_away_goals=result.get("alt_pred_away_goals") if result_dict else result.alt_pred_away_goals,
pred_1x2=result.get("pred_1x2") if result_dict else result.pred_1x2,
subjective_confidence=result.get("subjective_confidence") if result_dict else result.subjective_confidence,
reasoning=result.get("reasoning") if result_dict else result.reasoning,
status=result.get("status", "success") if result_dict else getattr(result, "status", "success"),
agent_outputs=result.get("agent_outputs") if result_dict else getattr(result, "agent_outputs", None),
agent_weights=result.get("agent_weights") if result_dict else getattr(result, "agent_weights", None),
context=result.get("context", "") if result_dict else result.context,
latency_ms=result.get("latency_ms", 0) if result_dict else result.latency_ms,
prompt_tokens=result.get("prompt_tokens") if result_dict else getattr(result, "prompt_tokens", None),
completion_tokens=result.get("completion_tokens") if result_dict else getattr(result, "completion_tokens", None),
rate_limit_remaining=_predict_limiter.remaining(get_client_ip(request)),
)
async def _persist_baseline(match_id: int, baseline: dict) -> int:
"""将基线预测结果写入 prediction 表,复用 upsert 语义。"""
from src.db.unit_of_work import get_uow
from src.llm.predict import _upsert_prediction
async with get_uow() as session:
pred = await _upsert_prediction(
session,
match_id=match_id,
provider_name="baseline",
model="baseline",
mode="baseline",
run_type="baseline",
values={
"prompt_version": "baseline_v1",
"prompt_tokens": 0,
"completion_tokens": 0,
"latency_ms": 0,
"pred_home_goals": baseline["pred_home_goals"],
"pred_away_goals": baseline["pred_away_goals"],
"pred_1x2": baseline["pred_1x2"],
"subjective_confidence": baseline["subjective_confidence"],
"reasoning": baseline["reasoning"],
"raw_response": baseline.get("raw", baseline),
"status": "success",
},
)
return pred.id
@router.get("/predictions", response_model=list[PredictionOut], dependencies=[Depends(require_admin)])
async def list_predictions(
match_id: int | None = None,
@@ -110,10 +155,14 @@ async def list_predictions(
mode=p.mode or "single",
pred_home_goals=p.pred_home_goals,
pred_away_goals=p.pred_away_goals,
alt_pred_home_goals=p.alt_pred_home_goals,
alt_pred_away_goals=p.alt_pred_away_goals,
pred_1x2=p.pred_1x2,
subjective_confidence=p.subjective_confidence,
reasoning=p.reasoning,
status=p.status or "success",
agent_outputs=p.agent_outputs,
agent_weights=p.agent_weights,
created_at=p.created_at,
actual_home_goals=p.actual_home_goals,
actual_away_goals=p.actual_away_goals,
@@ -137,10 +186,14 @@ async def get_prediction(prediction_id: int, db: AsyncSession = Depends(get_db_r
mode=p.mode or "single",
pred_home_goals=p.pred_home_goals,
pred_away_goals=p.pred_away_goals,
alt_pred_home_goals=p.alt_pred_home_goals,
alt_pred_away_goals=p.alt_pred_away_goals,
pred_1x2=p.pred_1x2,
subjective_confidence=p.subjective_confidence,
reasoning=p.reasoning,
status=p.status or "success",
agent_outputs=p.agent_outputs,
agent_weights=p.agent_weights,
created_at=p.created_at,
actual_home_goals=p.actual_home_goals,
actual_away_goals=p.actual_away_goals,
+18 -1
View File
@@ -29,6 +29,8 @@ class MatchOut(BaseModel):
match_stage: str | None
home_xg: float | None = None
away_xg: float | None = None
# 该场比赛的最近预测摘要(按时间倒序,最多 5 条;无预测为空)
recent_predictions: list[PredictionOut] = []
class MatchListOut(BaseModel):
@@ -42,7 +44,13 @@ class PredictRequest(BaseModel):
provider: str | None = None
model: str | None = None
prompt_version: str | None = None
mode: str = "multi" # multi(默认, 5专家+终裁) | single(单次调用)
mode: str = Field(
"multi",
description="multi(默认,5专家+终裁) | single(单次) | baseline(极简统计基线,不调用 LLM)",
)
use_cache: bool = True
backtest: bool = False
cutoff_at: str | None = Field(None, description="显式截止时间 ISO8601,用于回测防未来信息")
class PredictOut(BaseModel):
@@ -58,6 +66,13 @@ class PredictOut(BaseModel):
pred_1x2: str | None
subjective_confidence: float | None
reasoning: str | None
status: str = "success"
# 成本信息(可选;单次/多专家均有)
latency_ms: int | None = None
prompt_tokens: int | None = None
completion_tokens: int | None = None
# 限流提示:当请求被节流时告知用户剩余配额(可选)
rate_limit_remaining: int | None = None
agent_outputs: list[dict] | None = None
agent_weights: dict | None = None
context: str
@@ -78,7 +93,9 @@ class PredictionOut(BaseModel):
pred_1x2: str | None
subjective_confidence: float | None
reasoning: str | None
status: str = "success"
agent_outputs: list[dict] | None = None
agent_weights: dict | None = None
created_at: datetime
actual_home_goals: int | None
actual_away_goals: int | None