"""伤停数据采集器(api-football / api-sports.io)。 采集伤停数据并入库(injuries 表),供 injuries agent 使用。 """ from __future__ import annotations import asyncio import json import logging import random import tempfile import time from datetime import datetime, timezone from pathlib import Path from typing import Any import httpx from src.core.config import settings from src.core.http_client import get_client logger = logging.getLogger(__name__) API_BASE = "https://v3.football.api-sports.io" DEFAULT_HOST = "v3.football.api-sports.io" # P2-3: 缓存目录改用系统临时目录,避免源码树内写入 _CACHE_DIR = Path(tempfile.gettempdir()) / "profeto_injuries" async def fetch_injuries(*, date: str | None = None, fixture_id: int | None = None, league_id: int | None = None) -> list[dict]: """采集伤停数据。 Args: date: 日期 (YYYY-MM-DD),返当天全部伤停 fixture_id: 指定比赛 ID league_id: 指定联赛 ID Returns: 伤停记录列表 """ api_key = settings.API_FOOTBALL_KEY if not api_key: raise RuntimeError("API_FOOTBALL_KEY 未设置") cache_dir = _CACHE_DIR cache_dir.mkdir(parents=True, exist_ok=True) # 缓存命中 (7 天内有效) cache_key = f"injuries_{date}_{fixture_id}_{league_id}.json" cache_file = cache_dir / cache_key if cache_file.exists(): age_hours = (time.time() - cache_file.stat().st_mtime) / 3600 if age_hours < 168: # 7 天 logger.debug("injuries cache hit: %s (%.1fh old)", cache_key, age_hours) with open(cache_file, encoding="utf-8") as f: return json.load(f) else: logger.debug("injuries cache expired: %s (%.1fh old)", cache_key, age_hours) headers = { "x-apisports-key": api_key, "x-rapidapi-host": DEFAULT_HOST, } params: dict[str, Any] = {} if date: params["date"] = date if fixture_id: params["fixture"] = fixture_id if league_id: params["league"] = league_id url = f"{API_BASE}/injuries" # 重试 last_exc: Exception | None = None for attempt in range(3): try: client = get_client() resp = await client.get(url, headers=headers, params=params, timeout=30) resp.raise_for_status() break except Exception as e: last_exc = e if attempt == 2: raise delay = min(2 ** attempt, 8) + random.uniform(0, 1) logger.warning("injuries fetch failed, retry %d in %.1fs: %s", attempt + 1, delay, e) await asyncio.sleep(delay) else: raise RuntimeError(f"injuries fetch failed: {last_exc}") data = resp.json() injuries = data.get("response", []) # 写缓存 with open(cache_file, "w", encoding="utf-8") as f: json.dump(injuries, ensure_ascii=False, default=str, fp=f) return injuries async def ingest_injuries(db, *, date: str | None = None) -> dict: """采集伤停数据并入库(injuries 表)。 注意: 本方法不控制事务(commit/rollback),由调用方通过 UnitOfWork 控制。 P1-4: 批量幂等检查,避免逐条查询的竞态条件(并发采集时 IntegrityError)。 """ from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import selectinload from src.data.team_names import normalize as normalize_name from src.db.models import Injury, Team result = {"count": 0, "inserted": 0, "errors": []} try: raw_injuries = await fetch_injuries(date=date) except Exception as e: logger.exception("injuries fetch failed") result["errors"].append(f"fetch failed: {e}") return result result["count"] = len(raw_injuries) # 预加载所有球队(用于按名匹配) teams = (await db.execute(select(Team))).scalars().all() team_by_name = {t.name: t.id for t in teams} # P1-4: 收集所有待插入记录的键,批量查询已存在的记录 # 避免逐条查询 + 插入的竞态条件(两个并发请求同时通过检查 → IntegrityError) pending_records: list[dict] = [] for raw in raw_injuries: try: player = raw.get("player", {}) or {} team = raw.get("team", {}) or {} fixture = raw.get("fixture", {}) or {} player_name = player.get("name", "") team_name = normalize_name(team.get("name", "")) team_id = team_by_name.get(team_name) # 解析日期 fixture_date = fixture.get("date") injury_date = None if fixture_date: try: dt = datetime.fromisoformat(fixture_date.replace("Z", "+00:00")) injury_date = dt.date() except (ValueError, AttributeError): pass # 强制 int 转换,API 可能返回字符串 player_id = player.get("id") try: player_id = int(player_id) if player_id is not None else None except (ValueError, TypeError): player_id = None fixture_id = fixture.get("id") try: fixture_id = int(fixture_id) if fixture_id is not None else None except (ValueError, TypeError): fixture_id = None pending_records.append({ "player_id": player_id, "player_name": player_name, "team_id": team_id, "fixture_id": fixture_id, "league_id": (raw.get("league") or {}).get("id"), "injury_type": player.get("type"), "reason": player.get("reason"), "injury_date": injury_date, }) except Exception as e: result["errors"].append(f"parse error: {e}") # P1-4: 批量查询已存在的记录(1 次 DB 往返) existing_keys: set[tuple] = set() if pending_records: # 构造查询条件:所有 (player_id, fixture_id, injury_type) 组合 # 使用 OR 条件批量查询 conditions = [] for rec in pending_records: conditions.append( (Injury.player_id == rec["player_id"]) & (Injury.fixture_id == rec["fixture_id"]) & (Injury.injury_type == rec["injury_type"]) ) if conditions: from sqlalchemy import or_ stmt = select(Injury.player_id, Injury.fixture_id, Injury.injury_type).where(or_(*conditions)) rows = (await db.execute(stmt)).all() existing_keys = {(r[0], r[1], r[2]) for r in rows} # P1-4: 批量插入(跳过已存在的) for rec in pending_records: key = (rec["player_id"], rec["fixture_id"], rec["injury_type"]) if key in existing_keys: continue injury = Injury(**rec) db.add(injury) result["inserted"] += 1 # 每 50 条 flush 一次,减少内存压力,同时捕获 IntegrityError if result["inserted"] % 50 == 0: try: await db.flush() except IntegrityError: # P1-4: 并发采集时可能仍有竞态,回退到逐条插入 await db.rollback() logger.warning("injuries batch IntegrityError, falling back to per-record insert") return await _ingest_injuries_fallback(db, pending_records, result) # 最终 flush try: await db.flush() except IntegrityError: await db.rollback() logger.warning("injuries final flush IntegrityError, falling back to per-record insert") return await _ingest_injuries_fallback(db, pending_records, result) # 注意: 不在此处 commit,由调用方 UnitOfWork 控制事务 logger.info("injuries: fetched %d, inserted %d for %s", result["count"], result["inserted"], date) return result async def _ingest_injuries_fallback(db, pending_records: list[dict], result: dict) -> dict: """P1-4: 逐条插入回退,捕获每条 IntegrityError 避免整批回滚。""" from sqlalchemy.exc import IntegrityError from src.db.models import Injury inserted = 0 for rec in pending_records: injury = Injury(**rec) db.add(injury) try: await db.flush() inserted += 1 except IntegrityError: await db.rollback() # 已存在或其他冲突,跳过 continue result["inserted"] = inserted logger.info("injuries fallback: inserted %d records", inserted) return result async def get_injuries_for_match(db, team_id: int, match_date, as_of=None) -> list[Injury]: """查询某场比赛前某队的伤停名单(比赛日仍缺阵的)。 Args: db: 数据库 session team_id: 球队 ID match_date: 比赛日期 as_of: 数据截止时间(用于回测防泄漏) Returns: 伤停记录列表 """ from sqlalchemy import select from src.db.models import Injury if hasattr(match_date, "date") and callable(match_date.date): match_date = match_date.date() stmt = ( select(Injury) .where(Injury.team_id == team_id) .where(Injury.injury_date <= match_date) .where( (Injury.return_date.is_(None)) | (Injury.return_date >= match_date) ) ) if as_of is not None: if hasattr(as_of, "date") and callable(as_of.date): as_of = as_of.date() stmt = stmt.where(Injury.retrieved_at <= as_of) result = await db.execute(stmt) return list(result.scalars().all())