fix(P0): 修复三处静默失效的阻断缺陷
P0-1 backtest 会话生命周期: - _get_historical_matches 加 selectinload(league/home_team/away_team), 并在 session 内物化为 BacktestCandidate 纯数据快照,避免 session 关闭后访问惰性关系抛 MissingGreenlet - except 收窄并改用 logger.exception 保留堆栈 - 移除未使用的 and_ / League 导入 P0-2 切片函数关系属性 MissingGreenlet: - models.py 为 Match.league/home_team/away_team/stats 声明 lazy=selectin - context_builder 的 _get_form/_get_h2h/_get_home_away 显式 selectinload (修复 form_slice/h2h_slice 恒定失败,被 fail-open 掩盖的问题) - repositories.find_by_teams_and_date 加 selectinload(Match.stats) P0-3 bzzoiro raw 变量泄漏导致血缘错乱: - normalized_matches 改为携带 (nm, raw) 元组,内层循环解包 - source_event_id / source_record_id 现在取到正确 event id - 已有比赛补建 stats 时补齐 source/source_event_id/retrieved_at/available_at - 抽取 _match_key()/_to_date() 统一日期键构造 (P1-6)
This commit is contained in:
+36
-8
@@ -27,6 +27,26 @@ from src.db.models import League, Match, MatchStats, Team
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _to_date(value):
|
||||
"""把 datetime / date / str 统一成 `date`。"""
|
||||
if value is None:
|
||||
return None
|
||||
if hasattr(value, "date") and callable(value.date):
|
||||
return value.date()
|
||||
return value
|
||||
|
||||
|
||||
def _match_key(home_team_id: int, away_team_id: int, match_date) -> tuple[int, int, str]:
|
||||
"""比赛去重键:(主队, 客队, 天级日期 ISO 字符串)。
|
||||
|
||||
统一在这里构造,避免"预加载时用 str(date)、写入时用 isoformat()"这类
|
||||
隐式格式依赖 —— 两者当前恰好相等,但一旦有人改动其一就会静默失配,
|
||||
导致所有比赛被判为不存在而重复插入。
|
||||
"""
|
||||
d = _to_date(match_date)
|
||||
return (home_team_id, away_team_id, d.isoformat() if d is not None else "")
|
||||
|
||||
|
||||
def _fetch_json_sync(path: str, params: dict | None = None, max_retries: int = 3) -> dict | list:
|
||||
"""同步 HTTP(bzzoiro 客户端保持同步,在 async 函数里 run_in_executor)。"""
|
||||
base = settings.BZZOIRO_BASE.rstrip("/")
|
||||
@@ -153,7 +173,9 @@ class BzzoiroSource:
|
||||
# === 批量优化: 预加载球队和已有比赛到内存 ===
|
||||
team_name_to_id: dict[str, int] = {}
|
||||
existing_matches: dict[tuple[int, int, str], Match] = {} # 完整对象,避免重复查询
|
||||
normalized_matches: list = [] # 缓存规范化结果,避免重复调用
|
||||
# (NormalizedMatch, 原始 event) 成对保存:后续写 source_record_id 时
|
||||
# 必须用配对的那条 event,不能依赖外层循环变量残留值。
|
||||
normalized_matches: list[tuple] = []
|
||||
|
||||
if raw_events:
|
||||
# 一次遍历: 收集球队名 + 规范化
|
||||
@@ -167,7 +189,7 @@ class BzzoiroSource:
|
||||
logger.debug("normalize skip: %s", e)
|
||||
league_r["errors"].append(f"normalize: {e}")
|
||||
continue
|
||||
normalized_matches.append(nm)
|
||||
normalized_matches.append((nm, raw))
|
||||
all_team_names.add(nm.home_team)
|
||||
all_team_names.add(nm.away_team)
|
||||
|
||||
@@ -179,10 +201,10 @@ class BzzoiroSource:
|
||||
# 预加载已有比赛(完整对象)
|
||||
stmt = select(Match).where(Match.league_id == league.id)
|
||||
for m in (await db.execute(stmt)).scalars():
|
||||
key = (m.home_team_id, m.away_team_id, str(m.match_date_date))
|
||||
key = _match_key(m.home_team_id, m.away_team_id, m.match_date_date)
|
||||
existing_matches[key] = m
|
||||
|
||||
for nm in normalized_matches:
|
||||
for nm, raw in normalized_matches:
|
||||
# 球队: 内存查找 + 按需创建
|
||||
home_team_id = team_name_to_id.get(nm.home_team)
|
||||
if home_team_id is None:
|
||||
@@ -201,8 +223,7 @@ class BzzoiroSource:
|
||||
team_name_to_id[nm.away_team] = away_team_id
|
||||
|
||||
# 查找已有比赛: 内存查找
|
||||
date_key = nm.date.date().isoformat() if hasattr(nm.date, "date") else str(nm.date)
|
||||
match_key = (home_team_id, away_team_id, date_key)
|
||||
match_key = _match_key(home_team_id, away_team_id, nm.date)
|
||||
existing_match = existing_matches.get(match_key)
|
||||
|
||||
if existing_match is None:
|
||||
@@ -212,7 +233,7 @@ class BzzoiroSource:
|
||||
home_team_id=home_team_id,
|
||||
away_team_id=away_team_id,
|
||||
match_date=nm.date,
|
||||
match_date_date=nm.date.date() if hasattr(nm.date, "date") else nm.date,
|
||||
match_date_date=_to_date(nm.date),
|
||||
match_status=nm.match_status,
|
||||
home_goals=nm.home_goals,
|
||||
away_goals=nm.away_goals,
|
||||
@@ -263,7 +284,14 @@ class BzzoiroSource:
|
||||
existing_match.match_stage = nm.match_stage
|
||||
changed = True
|
||||
if existing_match.stats is None and (nm.home_xg is not None or nm.away_xg is not None):
|
||||
existing_match.stats = MatchStats(match_id=existing_match.id)
|
||||
now = datetime.now(timezone.utc)
|
||||
existing_match.stats = MatchStats(
|
||||
match_id=existing_match.id,
|
||||
source="bzzoiro",
|
||||
source_event_id=str(raw.get("id", "")),
|
||||
retrieved_at=now,
|
||||
available_at=now,
|
||||
)
|
||||
db.add(existing_match.stats)
|
||||
await db.flush()
|
||||
if existing_match.stats is not None:
|
||||
|
||||
Reference in New Issue
Block a user