Files
Profeto/src/db/repositories.py
T
WorkBuddy 4514ef4e92 debt(D4): bzzoiro Team/League/Match 查找经 Repository 层,纠正空壳表述
- repositories.py 新增 MatchRepository.find_by_league_and_date_range /
  find_finished_with_stats(含 stats 预加载),TeamRepository.get_or_create
  支持 name_zh
- bzzoiro events/standings/stats 三条管线的联赛、球队、比赛查找与批量预载
  全部改走 Repository;事务提交仍由调用方 UnitOfWork 控制
- Standing/RawEvent/Lineage 管线内私有读写保留在本模块(不强行 Repository 化)
- 模块 docstring 纠正为准确表述,移除「已全面 Repository 化」的误导性声明
2026-09-21 19:48:56 +08:00

167 lines
5.8 KiB
Python

"""Repository 层:封装数据访问。
Repository 只负责查询,不负责事务提交。
事务由 Application Service 通过 UnitOfWork 控制。
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models import League, Match, Prediction, Team
class MatchRepository:
"""比赛数据访问。"""
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def get_by_id(self, match_id: int) -> Match | None:
return await self._session.get(Match, match_id)
async def get_with_relations(self, match_id: int) -> Match | None:
stmt = (
select(Match)
.options(
selectinload(Match.league),
selectinload(Match.home_team),
selectinload(Match.away_team),
selectinload(Match.stats),
)
.where(Match.id == match_id)
)
return (await self._session.execute(stmt)).scalar_one_or_none()
async def find_by_teams_and_date(
self, league_id: int, home_team_id: int, away_team_id: int, date
) -> Match | None:
"""按联赛+主队+客队+日期查找比赛(天级匹配)。
预加载 stats:调用方(统计回填)会读取 existing.stats,
async session 下惰性加载会抛 MissingGreenlet。
P2-3: 使用 match_date_date(已建索引)做等值匹配,避免 func.date()
导致的全表扫描。
"""
if isinstance(date, datetime):
date = date.date()
elif hasattr(date, "date"):
date = date.date()
else:
# 字符串等其它格式,尝试转换
date = datetime.fromisoformat(str(date)).date()
stmt = (
select(Match)
.options(selectinload(Match.stats))
.where(Match.league_id == league_id)
.where(Match.home_team_id == home_team_id)
.where(Match.away_team_id == away_team_id)
.where(Match.match_date_date == date)
)
return (await self._session.execute(stmt)).scalar_one_or_none()
async def find_by_league_and_date_range(
self, league_id: int, start, end
) -> list[Match]:
"""批量预加载某联赛日期范围内的比赛(ingest 管线内存去重用)。"""
stmt = (
select(Match)
.where(Match.league_id == league_id)
.where(Match.match_date >= start)
.where(Match.match_date <= end)
)
return (await self._session.execute(stmt)).scalars().all()
async def find_finished_with_stats(self, league_ids: list[int], *, limit: int) -> list[Match]:
"""已完赛且有上游 event id 的比赛(按日期倒序),供统计回填逐场拉取。
预加载 stats:调用方需读取 existing.stats 判断是否跳过。
"""
stmt = (
select(Match)
.options(selectinload(Match.stats))
.where(Match.match_status == "finished")
.where(Match.source_event_id.is_not(None))
.where(Match.league_id.in_(league_ids))
.order_by(Match.match_date.desc())
.limit(limit)
)
return (await self._session.execute(stmt)).scalars().all()
async def add(self, match: Match) -> None:
self._session.add(match)
await self._session.flush()
class TeamRepository:
"""球队数据访问。"""
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def get_by_name(self, name: str) -> Team | None:
stmt = select(Team).where(Team.name == name)
return (await self._session.execute(stmt)).scalar_one_or_none()
async def get_or_create(self, name: str, *, name_zh: str | None = None) -> Team:
"""按名获取球队,不存在则创建(name_zh 供 bzzoiro 管线写中文名)。"""
team = await self.get_by_name(name)
if team is None:
team = Team(name=name, name_zh=name_zh)
self._session.add(team)
await self._session.flush()
return team
async def get_all_by_names(self, names: list[str]) -> dict[str, Team]:
"""批量获取球队,返回 name → Team 映射。"""
if not names:
return {}
stmt = select(Team).where(Team.name.in_(names))
teams = (await self._session.execute(stmt)).scalars().all()
return {t.name: t for t in teams}
async def add(self, team: Team) -> None:
self._session.add(team)
await self._session.flush()
class LeagueRepository:
"""联赛数据访问。"""
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def get_by_code(self, code: str) -> League | None:
stmt = select(League).where(League.code == code)
return (await self._session.execute(stmt)).scalar_one_or_none()
async def get_or_create(self, code: str, name: str, country: str | None = None) -> League:
league = await self.get_by_code(code)
if league is None:
league = League(code=code, name=name, country=country)
self._session.add(league)
await self._session.flush()
return league
async def add(self, league: League) -> None:
self._session.add(league)
await self._session.flush()
class PredictionRepository:
"""预测记录数据访问。"""
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def get_by_id(self, prediction_id: int) -> Prediction | None:
return await self._session.get(Prediction, prediction_id)
async def add(self, prediction: Prediction) -> None:
self._session.add(prediction)
await self._session.flush()