Files
Profeto/docs/05-data.md
T
WorkBuddy 983363dab7 feat(ingest): 采集任务状态跟踪(ingest_jobs),解决 fire-and-forget 不可观测
- 新表 ingest_jobs(迁移 0019): id UUID/task/params JSONB/
  status(pending|running|success|failed,CheckConstraint)/
  result JSONB(统计摘要)/error/created_at/started_at/finished_at
- POST /ingest/bzzoiro: 启动后台前创建 pending job,响应返回 job_id;
  仍 require_admin。后台 _run_bzzoiro 流转 running→success/failed,
  result 按子任务(events/standings/stats)记录摘要(errors 截断 10 条)
- _update_job 尽力而为: 状态更新失败只记日志,绝不拖垮采集主流程;
  与 IngestFailure 死信独立(行级 vs 任务级,可同时存在)
- 新增 admin 端点(挂 /api/v1/admin 路由,路由级 require_admin):
  GET /admin/ingest/jobs/{job_id} 与 GET /admin/ingest/jobs?limit&status
- 前端采集页: 提交后凭 job_id 3 秒轮询,终态展示结果摘要/失败原因;
  无 job_id 时回退旧的 30 秒盲等 + 系统日志提示
- 测试 11 项: 建 job+job_id 契约、非法 task 422、成功/失败/all 流转、
  update 失败不拖垮采集、部分失败仍 success、admin 端点 200/404/列表、
  结构守护(job 路由在 admin 路由且带 require_admin)
- 禁止项确认: 未动分批 UoW、BzzoiroSource、死信与 Bronze 写入;
  docs(01/03/05/07/README)同步 13 张表与端点说明
2026-09-21 21:19:21 +08:00

205 lines
8.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`)
- 比分 030,xG 020,射门/射正/角球 0100,红黄牌 020,控球率 0100
- 半场比分 ≤ 全场比分
- 无比分小数(`_to_int` 严格: `"2.8"` → `None`,不截断)
### 队名归一化
`src/data/team_names.py` 维护 `NORMALIZE_MAP`(如 `Man City` → `Manchester City`),未命中映射的队名原样返回。
归一前先做 Unicode NFKD 去重音。
## 数据库 Schema
12 张业务/配置表 + 1 张任务状态表(`ingest_jobs`):核心业务表 5 张见下方 DDL,其余见后文表格。
```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
);
```
其余 8 张表(DDL 略,详见 `src/db/models.py` 与 alembic 迁移):
| 表 | 状态 | 用途 |
|---|---|---|
| `standings` | 已启用 | 联赛积分榜快照,按 `(league_id, season, team_id)` upsert,同联赛同赛季只保留最新快照;含排名/战绩/进失球/积分/分区(zone) |
| `app_settings` | 已启用 | 后台运行时设置(如数据源 API Key),读取时优先于 `.env` 默认值 |
| `schedules` | 已启用 | 定时采集任务配置(task/cron/leagues/enabled),供内置调度器执行 |
| `ingest_jobs` | 已启用 | 采集任务状态:POST ingest 创建 pending,后台流转 running→success/failed;result 存统计摘要,供 admin 轮询(任务级,与死信互补) |
| `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. **`predictions` 级联删除**: `ON DELETE CASCADE`,删比赛时自动清其预测。
4. **`mode` + `prompt_version`**: `single` 模式存 `v1`/`v2`,`multi` 模式存 `multi_v1`/`multi_v2`,eval summary 按这两列天然分组对比。
## 入库语义(幂等)
`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。