feat: 核心模块增强 — 加密 + 运行时配置 + 日志缓冲
- crypto.py: API Key 加密/解密工具 - runtime_config.py: 运行时动态配置管理 - log_buffer.py: 内存日志缓冲区 - config.py: 新增加密配置项 - http_client.py: 增强重试和错误处理
This commit is contained in:
@@ -15,17 +15,29 @@ from src.core.config import settings
|
||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
from src.db.base import init_db
|
||||
from src.core.http_client import close_client
|
||||
from src.core.runtime_config import (
|
||||
ensure_admin_password_hashed,
|
||||
migrate_plaintext_sensitive_settings,
|
||||
)
|
||||
await init_db() # 验证连接,不建表
|
||||
await migrate_plaintext_sensitive_settings() # 明文敏感配置 → 加密(幂等)
|
||||
await ensure_admin_password_hashed() # .env 明文密码 → scrypt 哈希(幂等)
|
||||
yield
|
||||
await close_client()
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
from src.core.log_buffer import setup_memory_logging
|
||||
setup_memory_logging(settings.LOG_LEVEL)
|
||||
|
||||
# 生产环境不暴露 OpenAPI 文档(避免向访客泄露接口结构)
|
||||
openapi_url = "/openapi.json" if settings.APP_ENV != "production" else None
|
||||
app = FastAPI(
|
||||
title="Profeto API",
|
||||
description="足球数据 + LLM 预测服务",
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
openapi_url=openapi_url,
|
||||
)
|
||||
|
||||
origins = [o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip()]
|
||||
@@ -44,12 +56,16 @@ def create_app() -> FastAPI:
|
||||
from src.api.routes.ingest import router as ingest_router
|
||||
from src.api.routes.eval import router as eval_router
|
||||
from src.api.routes.backtest import router as backtest_router
|
||||
from src.api.routes.auth import router as auth_router
|
||||
from src.api.routes.admin_settings import router as admin_settings_router
|
||||
|
||||
app.include_router(matches_router)
|
||||
app.include_router(predict_router)
|
||||
app.include_router(ingest_router)
|
||||
app.include_router(eval_router)
|
||||
app.include_router(backtest_router)
|
||||
app.include_router(auth_router)
|
||||
app.include_router(admin_settings_router)
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
|
||||
+77
-16
@@ -1,37 +1,98 @@
|
||||
"""API 依赖:鉴权等横切关注点。
|
||||
|
||||
审查报告 P2-7:ingest / backtest / settle 这类「写入型或高成本」接口此前
|
||||
完全无鉴权 —— 任何能访问到服务的人都可触发采集、或直接烧掉 LLM 额度。
|
||||
|
||||
策略(渐进式,不破坏本地开发):
|
||||
- `ADMIN_API_KEY` 未配置 → 直接放行,并打一次 warning。
|
||||
这样本地 `docker compose up` 无需额外配置即可用。
|
||||
- 已配置 → 必须带匹配的 `X-API-Key` 请求头,否则 401。
|
||||
- 管理员密码:库中 scrypt 哈希优先,回落 .env 初始值;后台可在线修改。
|
||||
- 密码已配置(哈希或 .env)→ 管理后台可用密码登录,登录后颁发 HttpOnly
|
||||
Cookie 会话;受保护接口接受 Cookie 会话或 X-API-Key。
|
||||
- 仅配置 `ADMIN_API_KEY` → 受保护接口只接受 `X-API-Key` 请求头(机器/脚本调用)。
|
||||
- 两者都未配置 → 直接放行,并打一次 warning(本地开发模式)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from fastapi import Header, HTTPException
|
||||
from fastapi import Header, HTTPException, Request
|
||||
|
||||
from src.core.config import settings
|
||||
from src.core.runtime_config import get_admin_password_hash
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SESSION_COOKIE = "profeto_session"
|
||||
|
||||
async def require_admin_key(x_api_key: str | None = Header(None, alias="X-API-Key")) -> None:
|
||||
"""保护「写入型 / 高成本」接口的依赖。
|
||||
|
||||
用法: `@router.post("/ingest/bzzoiro", dependencies=[Depends(require_admin_key)])`
|
||||
def _sign(exp_ts: int, secret: bytes) -> str:
|
||||
msg = f"profeto-admin:{exp_ts}".encode()
|
||||
return hmac.new(secret, msg, "sha256").hexdigest()
|
||||
|
||||
|
||||
async def get_session_secret() -> bytes:
|
||||
"""会话签名密钥 = HMAC(SECRET_KEY, 管理员凭证指纹)。
|
||||
|
||||
指纹来自密码哈希(密码本身永不参与签名):密码变更 → 指纹变化
|
||||
→ 全部旧会话失效,无需额外吊销机制。
|
||||
"""
|
||||
expected = settings.ADMIN_API_KEY
|
||||
if not expected:
|
||||
from src.core.runtime_config import get_admin_credential_fingerprint
|
||||
|
||||
fingerprint = await get_admin_credential_fingerprint()
|
||||
key = settings.SECRET_KEY or f"fallback:{settings.DATABASE_URL}"
|
||||
return hmac.new(key.encode(), b"session:" + fingerprint.encode(), "sha256").digest()
|
||||
|
||||
|
||||
def create_session_token(secret: bytes) -> str:
|
||||
exp_ts = int(time.time()) + settings.ADMIN_SESSION_TTL_HOURS * 3600
|
||||
return f"{exp_ts}.{_sign(exp_ts, secret)}"
|
||||
|
||||
|
||||
def verify_session_token(token: str, secret: bytes) -> bool:
|
||||
try:
|
||||
exp_raw, sig = token.split(".", 1)
|
||||
exp_ts = int(exp_raw)
|
||||
if exp_ts < int(time.time()):
|
||||
return False
|
||||
return secrets.compare_digest(sig, _sign(exp_ts, secret))
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
async def auth_configured() -> bool:
|
||||
"""是否已启用鉴权(密码哈希/.env 密码/API Key 任一)。"""
|
||||
return bool(
|
||||
await get_admin_password_hash()
|
||||
or settings.ADMIN_PASSWORD
|
||||
or settings.ADMIN_API_KEY
|
||||
)
|
||||
|
||||
|
||||
async def require_admin(
|
||||
request: Request,
|
||||
x_api_key: str | None = Header(None, alias="X-API-Key"),
|
||||
) -> None:
|
||||
"""统一保护管理接口:接受 Cookie 会话(密码登录)或 X-API-Key。
|
||||
|
||||
用法: `@router.get("/leagues", dependencies=[Depends(require_admin)])`
|
||||
"""
|
||||
if not await auth_configured():
|
||||
logger.warning(
|
||||
"ADMIN_API_KEY 未设置,采集/回测接口当前【无鉴权】。"
|
||||
"生产环境请设置该环境变量。"
|
||||
"管理员密码 / ADMIN_API_KEY 均未设置,管理接口当前【无鉴权】。"
|
||||
"生产环境请至少设置其中一项。"
|
||||
)
|
||||
return
|
||||
|
||||
if not x_api_key or not secrets.compare_digest(x_api_key, expected):
|
||||
raise HTTPException(status_code=401, detail="无效或缺失的 X-API-Key")
|
||||
# 1) Cookie 会话(密码登录颁发)
|
||||
token = request.cookies.get(SESSION_COOKIE)
|
||||
if token and verify_session_token(token, await get_session_secret()):
|
||||
return
|
||||
|
||||
# 2) X-API-Key(机器/脚本调用;key 明文只在内存中,与第三方交互必需)
|
||||
if (
|
||||
settings.ADMIN_API_KEY
|
||||
and x_api_key
|
||||
and secrets.compare_digest(x_api_key, settings.ADMIN_API_KEY)
|
||||
):
|
||||
return
|
||||
|
||||
raise HTTPException(status_code=401, detail="未登录或凭证无效")
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
@@ -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": "密码已修改,请用新密码重新登录"}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 采集任务失败")
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
+9
-5
@@ -1,7 +1,7 @@
|
||||
"""Pydantic schemas。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import date, datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -53,6 +53,8 @@ class PredictOut(BaseModel):
|
||||
mode: str = "single"
|
||||
pred_home_goals: float | None
|
||||
pred_away_goals: float | None
|
||||
alt_pred_home_goals: int | None = None
|
||||
alt_pred_away_goals: int | None = None
|
||||
pred_1x2: str | None
|
||||
subjective_confidence: float | None
|
||||
reasoning: str | None
|
||||
@@ -71,6 +73,8 @@ class PredictionOut(BaseModel):
|
||||
mode: str = "single"
|
||||
pred_home_goals: float | None
|
||||
pred_away_goals: float | None
|
||||
alt_pred_home_goals: int | None = None
|
||||
alt_pred_away_goals: int | None = None
|
||||
pred_1x2: str | None
|
||||
subjective_confidence: float | None
|
||||
reasoning: str | None
|
||||
@@ -82,10 +86,10 @@ class PredictionOut(BaseModel):
|
||||
|
||||
|
||||
class IngestBzzoiroRequest(BaseModel):
|
||||
leagues: list[str] = Field(..., description="联赛代码列表,如 ['E0','SP1']")
|
||||
leagues: list[str] = Field(default_factory=list, description="联赛代码列表,如 ['E0','SP1'];空 = 全部已知联赛")
|
||||
date_from: str | None = None
|
||||
date_to: str | None = None
|
||||
status: str = "finished"
|
||||
status: str | None = Field(None, description="finished/scheduled;空 = 两者都采集")
|
||||
|
||||
|
||||
class IngestResponse(BaseModel):
|
||||
@@ -96,8 +100,8 @@ class IngestResponse(BaseModel):
|
||||
|
||||
|
||||
class IngestUnderstatRequest(BaseModel):
|
||||
league: str = Field(..., description="联赛代码,如 'E0'")
|
||||
season: int = Field(..., description="赛季起始年,如 2025 表示 2025-2026 赛季")
|
||||
league: str | None = Field(None, description="联赛代码,如 'E0';空 = 全部已知联赛")
|
||||
season: int = Field(default_factory=lambda: date.today().year, description="赛季起始年,如 2025 表示 2025-2026 赛季")
|
||||
|
||||
|
||||
class IngestInjuriesRequest(BaseModel):
|
||||
|
||||
Reference in New Issue
Block a user