# 05 · 数据层与数据库 ## 数据源 | 数据源 | 用途 | 必需 Key | 说明 | |---|---|---|---| | bzzoiro(唯一) | 赛果/赛程/积分榜/统计(xG、射门、控球等) | `BZZOIRO_KEY` | 五大联赛 + 欧战,历史 + 实时 | > 历史版本曾有 understat(xG 回填)与 api-football(伤停)两个辅助源, > 现已移除:数据源收敛为 bzzoiro 唯一来源,统计与积分榜均由 bzzoiro 管线采集。 ### bzzoiro - 端点:`/api/v2/events/`,按 `league_id` + 日期范围分页 - 限速:`REQUEST_INTERVAL = 1.2s`,429 自动重试 3 次(轮换 key) - 联赛映射(`src/data/config.py`): ```python BZZOIRO_LEAGUE_IDS = {"E0": 1, "SP1": 3, "D1": 5, "I1": 4, "F1": 6, "CL": 7, "EL": 8} ``` > ⚠️ **统计字段映射待验证**:当前字段名基于常见足球 API 模式推测(如 `home_shots`/`away_shots`), > 未经真实 bzzoiro 响应校验。若真实字段不同,映射结果将为 None。 > 请提供一份 event 样例核对以下字段: > - 射门: `home_shots` / `away_shots` > - 射正: `home_shots_on_target` / `away_shots_on_target` > - 角球: `home_corners` / `away_corners` > - 控球: `home_possession` > - xG: `home_xg` / `away_xg` > - 黄牌: `home_yellow_cards` / `away_yellow_cards` > - 红牌: `home_red_cards` / `away_red_cards` ## 数据清洗契约 所有数据源统一清洗为 `NormalizedMatch`(`src/data/normalize.py`),字段: | 字段 | 类型 | 说明 | |---|---|---| | `league_type` | str | 联赛代码(小写,如 `E0`) | | `date` | datetime | UTC 时间 | | `home_team` / `away_team` | str | 归一化后的规范名 | | `match_status` | str | `finished`/`scheduled`/... | | `home_goals` / `away_goals` | int? | 全场比分 | | `home_ht_goals` / `away_ht_goals` | int? | 半场比分 | | `home_xg` / `away_xg` | float? | 期望进球 | | `home_shots` / `away_shots` | int? | 射门 | | `home_shots_on_target` / `away_shots_on_target` | int? | 射正 | | `home_corners` / `away_corners` | int? | 角球 | | `home_possession` | float? | 主队控球率 | | `home_yellow_cards` / `away_yellow_cards` | int? | 黄牌 | | `home_red_cards` / `away_red_cards` | int? | 红牌 | | `match_stage` | str? | 轮次(如"第 5 轮") | | `season_label` | str | 赛季标签(如 `2026-2027`) | ### 校验规则(`validate()`) - `finished` 无比分 → 抛错(或降级为 `scheduled`) - 比分 0–30,xG 0–20,射门/射正/角球 0–100,红黄牌 0–20,控球率 0–100 - 半场比分 ≤ 全场比分 - 无比分小数(`_to_int` 严格: `"2.8"` → `None`,不截断) ### 队名归一化 `src/data.team_names.py` 维护 `NORMALIZE_MAP`(如 `Man City` → `Manchester City`),未命中映射的队名原样返回。 归一前先做 Unicode NFKD 去重音。 **唯一键是归一后英文名**:`teams.name` 带 `UNIQUE` 约束,所有入库路径均经 `TeamRepository.get_or_create` 收敛归一化 (events / standings 管线在调用前归一,仓库层再做一次幂等归一作为兜底)。创建新 Team 时打 `info` 日志记录「原始名 → 归一后名」。 > ⚠️ **`normalize` 当前大小写敏感**:仅当入参大小写与 `NORMALIZE_MAP` 键完全匹配时才触发映射 >(如 `"Man City"` → `"Manchester City"`,但 `"man city"` 原样保留)。上游 bzzoiro 返回的队名首字母大写, >实际命中无问题;若新增数据源返回全小写/全大写队名,需先 `title()` 再归一,否则会绕过映射产生重复 Team。 #### 别名机制(`team_aliases`) 归一仍可能遗漏历史重复队(如 `"Bayern Munich"` 与 `"Bayern München"` 经 NFKD 后相同则命中, 但 `"Man United"` vs `"Manchester United"` 若漏映射)。`team_aliases` 表提供**显式别名→teams.id** 映射: | 列 | 说明 | |----|------| | `alias_normalized` | PK,`normalize(别名)` 后的稳定幂等键 | | `team_id` | FK → `teams.id`(ON DELETE CASCADE) | | `original_alias` | 原始写法(保留供参考) | **定位三步链**(`get_or_create`):`normalize(name)` → 查 `teams.name` → 查 `team_aliases`(以 `normalize(name)` 为 PK)→ 都没有才 insert 新 Team。别名命中即复用已有 Team,避免产生重复。 **添加别名**(不自动合并历史重复队): - **Admin 接口**(推荐):`POST /api/v1/admin/teams/aliases {"alias": "Man United", "team_id": 42}`(require_admin,幂等) - **直接 SQL**: ```sql INSERT INTO team_aliases(alias_normalized, team_id, original_alias) VALUES ('man united', 42, 'Man United') ON CONFLICT (alias_normalized) DO UPDATE SET team_id = EXCLUDED.team_id, original_alias = EXCLUDED.original_alias; ``` > ⚠️ **别名不自动合并**:发现历史重复队 A/B 后,需人工确认归一目标(如保留 B),再为 A 的归一名添加别名指向 B。 > 合并前请确认 A 的 `matches`/`standings` 引用是否需要迁移(可先 `SELECT COUNT(*) FROM matches WHERE home_team_id = A.id OR away_team_id = A.id` 评估)。 **改名 / 合并流程**(人工): 当发现两个 `teams` 行实际是同一球队(如 `Manchester City` 与 `Man City` 因历史数据大小写差异各占一行): 1. 确定**保留行**(通常选归一后规范名、且被更多 Match 引用的那行)。 2. 将被删行的所有引用指向保留行(`UPDATE matches SET home_team_id = 保留id WHERE home_team_id = 删行id`,客场同理; `standings` / `match_stats` 按 `team_id` 同理)。 3. 删掉多余行:`DELETE FROM teams WHERE id = 删行id`。 > 此过程引入外键约束风险,务必在事务中执行并先 `BEGIN; ... ` 验证行数后再 `COMMIT`。 > 暂不做自动合并(避免误合相似名),仅通过下方 Admin 接口列出「近似重名」候选,由人工判定。 ## Admin:近似重名候选 `GET /api/v1/admin/team-name-duplicates` 只读列出启发式相似候选(大小写差异、子串包含、前缀碰撞),不做自动合并。 典型用途:定期巡检,发现候选后走上方人工 SQL 合并。启发式规则: - **大小写变体**:`lower(name)` 相同但 `name` 不同(如 `Arsenal FC` / `arsenal fc`)。 - **子串包含**:A 是 B 的子串且长度 ≥ 5(如 `Manchester` / `Manchester City`)。 - **前缀碰撞**:前 8 个字符相同的两队。 命中任一规则即列为候选,按相似度分组返回。 ## 数据库 Schema 12 张表:核心业务表 5 张见下方 DDL,其余 7 张(积分榜/配置/调度/治理)见后文表格。 ```sql -- 联赛 CREATE TABLE leagues ( id SERIAL PRIMARY KEY, code VARCHAR(20) UNIQUE NOT NULL, -- 'E0' / 'SP1' name VARCHAR(100) NOT NULL, country VARCHAR(50), created_at TIMESTAMPTZ ); -- 球队 CREATE TABLE teams ( id SERIAL PRIMARY KEY, name VARCHAR(120) UNIQUE NOT NULL, -- 规范名(归一后) name_zh VARCHAR(60), -- 中文名 team_type VARCHAR(20) DEFAULT 'club', created_at TIMESTAMPTZ ); -- 比赛(核心) CREATE TABLE matches ( id SERIAL PRIMARY KEY, league_id INT REFERENCES leagues(id), season VARCHAR(12), -- '2026-2027' home_team_id INT REFERENCES teams(id), away_team_id INT REFERENCES teams(id), match_date TIMESTAMPTZ NOT NULL, match_date_date DATE NOT NULL, -- 天级日期(去重键) match_status VARCHAR(20) DEFAULT 'scheduled', home_goals INT, away_goals INT, home_ht_goals INT, away_ht_goals INT, match_stage VARCHAR(100), created_at TIMESTAMPTZ, updated_at TIMESTAMPTZ ); -- 唯一约束: 同联赛同对阵同天只存一场(天级去重) CREATE UNIQUE INDEX ix_matches_unique ON matches(league_id, home_team_id, away_team_id, match_date_date); -- 比赛统计(xG、射门、控球等) CREATE TABLE match_stats ( match_id INT PRIMARY KEY REFERENCES matches(id) ON DELETE CASCADE, home_xg FLOAT, away_xg FLOAT, home_shots INT, away_shots INT, home_shots_on_target INT, away_shots_on_target INT, home_corners INT, away_corners INT, home_possession FLOAT, home_yellow_cards INT, away_yellow_cards INT, home_red_cards INT, away_red_cards INT, updated_at TIMESTAMPTZ ); -- LLM 预测记录 CREATE TABLE predictions ( id SERIAL PRIMARY KEY, match_id INT REFERENCES matches(id) ON DELETE CASCADE, provider VARCHAR(30) NOT NULL, -- 'openai' / 'anthropic' model VARCHAR(80) NOT NULL, prompt_version VARCHAR(20) NOT NULL DEFAULT 'v1', mode VARCHAR(20) NOT NULL DEFAULT 'single', -- 'single' / 'multi' prompt_tokens INT, completion_tokens INT, latency_ms INT, pred_home_goals FLOAT, pred_away_goals FLOAT, pred_1x2 VARCHAR(3), -- '1' / 'X' / '2' subjective_confidence FLOAT, -- LLM 主观置信度(非概率) status VARCHAR(20) NOT NULL DEFAULT 'success', -- 'success' / 'failed' / 'degraded' match_kickoff_at TIMESTAMPTZ, -- 比赛时间 prediction_created_at TIMESTAMPTZ, -- 预测创建时间 prediction_cutoff_at TIMESTAMPTZ, -- 数据截止时间 input_hash VARCHAR(64), -- 输入快照 hash reasoning TEXT, raw_response JSONB, -- LLM 完整原始响应 agent_outputs JSONB, -- multi 模式: 5 份专家报告 agent_weights JSONB, -- multi 模式: 终裁给出的各专家权重 created_at TIMESTAMPTZ, actual_home_goals INT, actual_away_goals INT, -- 赛后回填 settled BOOLEAN DEFAULT FALSE ); ``` 其余 7 张表(DDL 略,详见 `src/db/models.py` 与 alembic 迁移): | 表 | 状态 | 用途 | |---|---|---| | `standings` | 已启用 | 联赛积分榜快照,按 `(league_id, season, team_id)` upsert,同联赛同赛季只保留最新快照;含排名/战绩/进失球/积分/分区(zone) | | `app_settings` | 已启用 | 后台运行时设置(如数据源 API Key),读取时优先于 `.env` 默认值 | | `schedules` | 已启用 | 定时采集任务配置(task/cron/leagues/enabled),供内置调度器执行 | | `raw_events` | 预留未启用 | Bronze 层原始事件存档;规划中用于重放与审计 | | `ingest_failures` | 已启用 | 采集失败死信:bzzoiro 三条管线(events/standings/stats)抓取失败时写入,admin 后台可查看与重试 | | `data_quality_checks` | 预留未启用 | 数据质量检查结果;规划中定时检查比赛/统计/积分榜完整性 | | `data_lineage` | 预留未启用 | ETL 血缘追踪;规划中记录源记录到目标表的映射 | ### 关键设计点 1. **`match_date_date`(天级日期)**: 用于天级去重。bzzoiro 返回的时间带时分秒,精确匹配不可靠,故拆出 `DATE` 列做唯一键。 2. **`ix_matches_unique`**: `(league_id, home_team_id, away_team_id, match_date_date)` 唯一,保证同一场比赛重复采集时 upsert 而非插入重复行。**业务唯一:同联赛同主客同自然天一条。** 3. **`source_event_id` 部分唯一**: `ix_matches_source_event_id_unique`(WHERE source_event_id IS NOT NULL)——上游 bzzoiro 的比赛 id,当非空时全局唯一。作用: - 统计回填(`/events/{id}/stats/`)与 Bronze 血缘(/events/ 采集)通过它定位比赛,不依赖自然键天级舍入; - 新采集行均带此 id,避免同一 upstream 比赛因时间戳差异绕开自然键产生重复。 - 存量空 source_event_id 历史行不受影响(不强制回填)。 4. **`predictions` 级联删除**: `ON DELETE CASCADE`,删比赛时自动清其预测。 5. **`mode` + `prompt_version`**: `single` 模式存 `v1`/`v2`,`multi` 模式存 `multi_v1`/`multi_v2`,eval summary 按这两列天然分组对比。 ## 采集 upsert 查找顺序 events 管线按以下优先级定位已有比赛,命中即复用(更新): 1. **`source_event_id`**(upstream event id,唯一索引命中)——最精确,跨自然键舍入差异; 2. **自然键**:`(league_id, home_team_id, away_team_id, match_date_date)`(内存去重,覆盖无 event id 的采集)。 两者都未命中 → insert 新比赛。 ## 入库语义(幂等) `ingest_bzzoiro` 的 upsert 逻辑: - **不存在**: 插入新比赛 + 初始 stats - **已存在**: 只补空字段 - 比分:只在原记录为 `None` 时覆盖 - 状态:只允许单向升级(`scheduled` → `finished`),防止完赛行被覆盖成赛程 - stats:只补空(`home_xg` 已有值时不覆盖) `task=stats` 只回填统计(xG/射门/控球等,也只补空),不创建比赛。 `task=standings` 按 `(league_id, season, team_id)` upsert 积分榜快照,同一联赛同一赛季只保留最新一份。 ## 采集建议 ```bash # 1. 首次采集: 5 大联赛近 2 赛季赛果 curl -X POST /api/v1/ingest/bzzoiro \ -d '{"leagues":["E0","SP1","D1","I1","F1"],"date_from":"2024-08-01","date_to":"2026-09-08"}' # 2. 增量采集(每日 cron): 只拉最近 7 天 curl -X POST /api/v1/ingest/bzzoiro \ -d '{"leagues":["E0"],"date_from":"2026-09-01","date_to":"2026-09-08"}' # 3. 积分榜 + 统计回填(xG/射门/控球,提升 stats/standings 专家质量) curl -X POST /api/v1/ingest/bzzoiro -d '{"task":"standings"}' curl -X POST /api/v1/ingest/bzzoiro -d '{"task":"stats","leagues":["E0"],"limit":300}' # 注:采集端点需管理员凭据(Cookie 会话或 X-API-Key 头),下同 ``` 建议用外部 cron(如系统 crontab)定时触发,不引入 worker/redis。