"""Repository 层:封装数据访问。 Repository 只负责查询,不负责事务提交。 事务由 Application Service 通过 UnitOfWork 控制。 """ from __future__ import annotations import logging logger = logging.getLogger(__name__) 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_by_source_event_id(self, source_event_id: int) -> Match | None: """按上游 event id 查找比赛(唯一命中,用于 upsert 优先路径)。 source_event_id 上有 partial unique 索引(WHERE IS NOT NULL), 同联赛同主客同天(自然键)与上游 event_id 共同保障同一场比赛 重复采集时 upsert 而非插入重复行。 """ stmt = ( select(Match) .options(selectinload(Match.stats)) .where(Match.source_event_id == source_event_id) ) return (await self._session.execute(stmt)).scalar_one_or_none() 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 管线写中文名)。 归一化咽喉 + 别名查找,三步定位: 1) normalize(name) → 查 teams.name 2) 查 team_aliases(以 normalize(name) 为幂等键)→ 复用已映射的 teams.id 3) 都没有 → insert 新 Team(归一名) 创建新 Team 时 info 打出原始名与归一后的规范名,便于排查重名。 不自动合并历史重复队;需显式添加别名。 """ from src.data.team_names import normalize as normalize_name from src.db.models import TeamAlias normalized = normalize_name(name) or name.strip() # 1) 归一名直查 teams team = await self.get_by_name(normalized) if team is not None: return team # 2) 别名查找:normalize(别名) 作为幂等键,命中即复用已有 Team alias = await self._session.get(TeamAlias, normalized) if alias is not None: team = await self._session.get(Team, alias.team_id) if team is not None: logger.info("Team 别名命中: %s -> %s(已有 id=%s)", name, normalized, team.id) return team # 3) 新建 Team(归一名) logger.info("创建新 Team: %s -> %s", name, normalized) team = Team(name=normalized, name_zh=name_zh) self._session.add(team) await self._session.flush() return team async def add_alias(self, alias: str, team_id: int) -> TeamAlias: """为已有 Team 添加别名。 幂等:以 normalize(alias) 为 PK,重复添加同一别名会 upsert。 不自动合并历史重复队,仅建立别名映射。 """ from src.data.team_names import normalize as normalize_name from src.db.models import TeamAlias normalized = normalize_name(alias) or alias.strip() existing = await self._session.get(TeamAlias, normalized) if existing is not None: existing.team_id = team_id # 允许重新指向 existing.original_alias = alias await self._session.flush() return existing row = TeamAlias(alias_normalized=normalized, team_id=team_id, original_alias=alias) self._session.add(row) await self._session.flush() return row 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()