feat: 球队实体一致性 — 归一化咽喉 + team_aliases 别名机制

events/standings 创建 Team 前均经 team_names.normalize(已有,确认),
TeamRepository.get_or_create 收敛为归一化唯一咽喉 + info 日志。

新增 team_aliases 表(NFKD 归一别名 → teams.id FK CASCADE),
定位三步链:normalize(name) → teams.name → team_aliases → insert。
不自动合并历史重复队;提供 POST /api/v1/admin/teams/aliases 显式添加。

迁移 0020_team_aliases + Admin 别名管理端点(admin_teams.py)。
全量测试 270 通过。
This commit is contained in:
shangfangjian
2026-09-22 00:27:53 +08:00
parent 4b0d6ee58a
commit e15b554ba3
10 changed files with 290 additions and 15 deletions
+45 -10
View File
@@ -114,24 +114,59 @@ class TeamRepository:
async def get_or_create(self, name: str, *, name_zh: str | None = None) -> Team:
"""按名获取球队,不存在则创建(name_zh 供 bzzoiro 管线写中文名)。
归一化咽喉:所有入库 Team.name 必须经过 team_names.normalize,
此处统一收敛,避免各调用点散落归一化逻辑导致重复 Team。
归一化咽喉 + 别名查找,三步定位:
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 None:
logger.info(
"创建新 Team: %s -> %s",
name, normalized,
)
team = Team(name=normalized, name_zh=name_zh)
self._session.add(team)
await self._session.flush()
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: