docs/05-data.md:业务唯一=同联赛同主客同自然天; source_event_id 用于统计回填与血缘,新增部分唯一索引说明与 upsert 查找顺序。 新增 ix_matches_source_event_id_unique(WHERE IS NOT NULL), 兼容存量空值历史行;MatchRepository.find_by_source_event_id; events upsert 优先按 event_id 定位,回退自然键。 迁移 0021 + 回归测试修复(find_by_source_event_id 方法调用误判)。
13 KiB
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):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:
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 因历史数据大小写差异各占一行):
- 确定保留行(通常选归一后规范名、且被更多 Match 引用的那行)。
- 将被删行的所有引用指向保留行(
UPDATE matches SET home_team_id = 保留id WHERE home_team_id = 删行id,客场同理;standings/match_stats按team_id同理)。 - 删掉多余行:
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 张(积分榜/配置/调度/治理)见后文表格。
-- 联赛
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 血缘追踪;规划中记录源记录到目标表的映射 |
关键设计点
-
match_date_date(天级日期): 用于天级去重。bzzoiro 返回的时间带时分秒,精确匹配不可靠,故拆出DATE列做唯一键。 -
ix_matches_unique:(league_id, home_team_id, away_team_id, match_date_date)唯一,保证同一场比赛重复采集时 upsert 而非插入重复行。业务唯一:同联赛同主客同自然天一条。 -
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 历史行不受影响(不强制回填)。
- 统计回填(
-
predictions级联删除:ON DELETE CASCADE,删比赛时自动清其预测。 -
mode+prompt_version:single模式存v1/v2,multi模式存multi_v1/multi_v2,eval summary 按这两列天然分组对比。
采集 upsert 查找顺序
events 管线按以下优先级定位已有比赛,命中即复用(更新):
source_event_id(upstream event id,唯一索引命中)——最精确,跨自然键舍入差异;- 自然键:
(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 积分榜快照,同一联赛同一赛季只保留最新一份。
采集建议
# 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。