refactor: Sprint 3 - 引入 UnitOfWork + Repository 架构

新增:
- src/db/unit_of_work.py: UnitOfWork 事务封装
- src/db/repositories.py: Match/Team/League/Prediction Repository

重构:
- 删除 src/data/match_lookup.py(由 Repository 替代)
- 数据源(bzzoiro/understat/injuries)不再自行 commit
- API 路由(ingest)改用 UnitOfWork
- LLM 服务(predict/orchestrator/eval/backtest)改用 UnitOfWork

事务边界统一由调用方控制,数据层不再自行决定 commit。
This commit is contained in:
shangfangjian
2026-09-15 00:42:05 +08:00
parent cb36dc3ef9
commit 483cb956ba
11 changed files with 281 additions and 117 deletions
+24 -7
View File
@@ -1,6 +1,7 @@
"""Understat xG 数据源。
迁移自旧项目 app/data/sources/understat.py,改成 async。
使用 Repository 模式进行数据访问,不直接控制事务。
"""
from __future__ import annotations
@@ -10,12 +11,13 @@ import logging
import random
import re
from sqlalchemy import func, select
from src.core.http_client import get_client
from src.data.config import FDCO_TO_UNDERSTAT, LEAGUE_NAMES
from src.data.match_lookup import find_existing_match
from src.data.normalize import normalize_understat
from src.data.sources import register
from src.db.models import League, Match, MatchStats
from src.db.models import League, Match, MatchStats, Team
logger = logging.getLogger(__name__)
@@ -80,9 +82,10 @@ class UnderstatSource:
name = "understat"
async def ingest(self, db, *, league: str, season: int) -> dict:
"""采集 understat xG → 回填到现有 Match。只回填 xG 字段,不创建新 Match。"""
from sqlalchemy import select
"""采集 understat xG → 回填到现有 Match。只回填 xG 字段,不创建新 Match。
注意: 本方法不控制事务(commit/rollback),由调用方通过 UnitOfWork 控制。
"""
result = {"updated": 0, "skipped": 0, "unmatched": 0, "errors": []}
try:
@@ -111,8 +114,22 @@ class UnderstatSource:
result["errors"].append(f"normalize: {e}")
continue
# 匹配已有 Match(天级)
existing = await find_existing_match(db, league_obj.id, nm.home_team, nm.away_team, nm.date)
# 匹配已有 Match(天级) - 直接查询
home_team = (await db.execute(select(Team).where(Team.name == nm.home_team))).scalar_one_or_none()
away_team = (await db.execute(select(Team).where(Team.name == nm.away_team))).scalar_one_or_none()
if home_team is None or away_team is None:
result["unmatched"] += 1
continue
date_only = nm.date.date() if hasattr(nm.date, "date") else nm.date
stmt = (
select(Match)
.where(Match.league_id == league_obj.id)
.where(Match.home_team_id == home_team.id)
.where(Match.away_team_id == away_team.id)
.where(func.date(Match.match_date) == date_only)
)
existing = (await db.execute(stmt)).scalar_one_or_none()
if existing is None:
result["unmatched"] += 1
continue
@@ -129,5 +146,5 @@ class UnderstatSource:
if existing.stats.away_xg is None and nm.away_xg is not None:
existing.stats.away_xg = nm.away_xg
await db.commit()
# 注意: 不在此处 commit,由调用方 UnitOfWork 控制事务
return result