安全加固 + 数据管线修复 + 质量改进(第2批)
安全加固: - /api/v1/predict 限流改用 get_client_ip 防 X-Forwarded-For 伪造 - 新增 TRUST_PROXY_HEADERS/REQUIRE_ADMIN_AUTH 配置,默认 fail-closed - 生产环境管理接口未配置鉴权时拒绝(503),不再放行 数据管线修复: - injuries IntegrityError 改用 begin_nested(SAVEPOINT)隔离批次 - injuries 空名单 vs 未配置语义区分(has_data 精确标记) - bzzoiro 统计字段映射(shots/possession/cards) + 入库条件放宽 - available_at 语义收紧(回测防泄漏) - team_names NFKD 去变音双重查找 + 补齐变体键 预测系统改进: - multi-agent 全专家失败时 status=degraded 跳过终裁 - agent_weights 独立持久化到 predictions 表 新增迁移: - 0014_predictions_agent_weights.py 新增测试(8个文件): - test_ip_spoofing.py: 限流防伪造 - test_require_admin_fail_closed.py: 生产 fail-closed - test_injuries_integrity_rollback.py: SAVEPOINT 隔离 - test_injuries_slice_semantic.py: 空名单 vs 未配置 - test_available_at.py: 回测防泄漏 - test_bzzoirot_stats.py: 统计字段映射 - test_team_names_normalize.py: NFKD 变体 - test_multi_agent_degraded.py: 全失败 degraded - test_agent_weights_persist.py: 权重持久化
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
"""回归测试: bzzoiro 采集链路正确映射射门/控球/角球/xG。
|
||||
|
||||
验证:
|
||||
1. normalize_bzzoiro 正确映射统计字段
|
||||
2. 入库条件不再强制要求 xG(任一统计字段即可)
|
||||
3. API 没有的字段保持 None,不伪造
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.data.normalize import NormalizedMatch, normalize_bzzoiro
|
||||
|
||||
|
||||
class TestNormalizeBzzoirotStats:
|
||||
"""normalize_bzzoiro 应正确映射统计字段。"""
|
||||
|
||||
def test_maps_shots(self):
|
||||
"""API 提供 shots 字段时应正确映射。"""
|
||||
raw = {
|
||||
"event_date": "2026-01-15T15:00:00Z",
|
||||
"status": "finished",
|
||||
"home_team": "Man City",
|
||||
"away_team": "Man United",
|
||||
"home_score": 2,
|
||||
"away_score": 1,
|
||||
"home_shots": 15,
|
||||
"away_shots": 8,
|
||||
"home_shots_on_target": 6,
|
||||
"away_shots_on_target": 3,
|
||||
}
|
||||
m = normalize_bzzoiro(raw, "E0")
|
||||
assert m is not None
|
||||
assert m.home_shots == 15
|
||||
assert m.away_shots == 8
|
||||
assert m.home_shots_on_target == 6
|
||||
assert m.away_shots_on_target == 3
|
||||
|
||||
def test_maps_corners(self):
|
||||
"""API 提供 corners 字段时应正确映射。"""
|
||||
raw = {
|
||||
"event_date": "2026-01-15T15:00:00Z",
|
||||
"status": "finished",
|
||||
"home_team": "Liverpool",
|
||||
"away_team": "Chelsea",
|
||||
"home_score": 1,
|
||||
"away_score": 1,
|
||||
"home_corners": 7,
|
||||
"away_corners": 4,
|
||||
}
|
||||
m = normalize_bzzoiro(raw, "E0")
|
||||
assert m is not None
|
||||
assert m.home_corners == 7
|
||||
assert m.away_corners == 4
|
||||
|
||||
def test_maps_possession(self):
|
||||
"""API 提供 possession 字段时应正确映射。"""
|
||||
raw = {
|
||||
"event_date": "2026-01-15T15:00:00Z",
|
||||
"status": "finished",
|
||||
"home_team": "Barcelona",
|
||||
"away_team": "Real Madrid",
|
||||
"home_score": 2,
|
||||
"away_score": 0,
|
||||
"home_possession": 62.5,
|
||||
}
|
||||
m = normalize_bzzoiro(raw, "SP1")
|
||||
assert m is not None
|
||||
assert m.home_possession == 62.5
|
||||
|
||||
def test_maps_xg(self):
|
||||
"""API 提供 xG 字段时应正确映射。"""
|
||||
raw = {
|
||||
"event_date": "2026-01-15T15:00:00Z",
|
||||
"status": "finished",
|
||||
"home_team": "Bayern",
|
||||
"away_team": "Dortmund",
|
||||
"home_score": 3,
|
||||
"away_score": 1,
|
||||
"home_xg": 2.5,
|
||||
"away_xg": 0.8,
|
||||
}
|
||||
m = normalize_bzzoiro(raw, "D1")
|
||||
assert m is not None
|
||||
assert m.home_xg == 2.5
|
||||
assert m.away_xg == 0.8
|
||||
|
||||
def test_maps_cards(self):
|
||||
"""API 提供 cards 字段时应正确映射。"""
|
||||
raw = {
|
||||
"event_date": "2026-01-15T15:00:00Z",
|
||||
"status": "finished",
|
||||
"home_team": "Arsenal",
|
||||
"away_team": "Tottenham",
|
||||
"home_score": 1,
|
||||
"away_score": 0,
|
||||
"home_yellow_cards": 2,
|
||||
"away_yellow_cards": 3,
|
||||
"home_red_cards": 0,
|
||||
"away_red_cards": 1,
|
||||
}
|
||||
m = normalize_bzzoiro(raw, "E0")
|
||||
assert m is not None
|
||||
assert m.home_yellow_cards == 2
|
||||
assert m.away_yellow_cards == 3
|
||||
assert m.home_red_cards == 0
|
||||
assert m.away_red_cards == 1
|
||||
|
||||
def test_missing_stats_stay_none(self):
|
||||
"""API 没有统计字段时应保持 None,不伪造。"""
|
||||
raw = {
|
||||
"event_date": "2026-01-15T15:00:00Z",
|
||||
"status": "finished",
|
||||
"home_team": "A",
|
||||
"away_team": "B",
|
||||
"home_score": 1,
|
||||
"away_score": 0,
|
||||
}
|
||||
m = normalize_bzzoiro(raw, "E0")
|
||||
assert m is not None
|
||||
# 无比分数据时不应有统计字段
|
||||
assert m.home_shots is None
|
||||
assert m.away_shots is None
|
||||
assert m.home_xg is None
|
||||
assert m.away_xg is None
|
||||
assert m.home_possession is None
|
||||
assert m.home_corners is None
|
||||
|
||||
def test_alternative_field_names(self):
|
||||
"""API 使用替代字段名时也应正确映射。"""
|
||||
raw = {
|
||||
"event_date": "2026-01-15T15:00:00Z",
|
||||
"status": "finished",
|
||||
"home_team": "A",
|
||||
"away_team": "B",
|
||||
"home_score": 1,
|
||||
"away_score": 0,
|
||||
"shots_home": 12,
|
||||
"shots_away": 6,
|
||||
"sot_home": 5,
|
||||
"sot_away": 2,
|
||||
"corners_home": 8,
|
||||
"corners_away": 3,
|
||||
"xg_home": 1.8,
|
||||
"xg_away": 0.5,
|
||||
}
|
||||
m = normalize_bzzoiro(raw, "E0")
|
||||
assert m is not None
|
||||
assert m.home_shots == 12
|
||||
assert m.away_shots == 6
|
||||
assert m.home_shots_on_target == 5
|
||||
assert m.away_shots_on_target == 2
|
||||
assert m.home_corners == 8
|
||||
assert m.away_corners == 3
|
||||
assert m.home_xg == 1.8
|
||||
assert m.away_xg == 0.5
|
||||
|
||||
|
||||
class TestIngestionCondition:
|
||||
"""入库条件应不再强制要求 xG。"""
|
||||
|
||||
def test_any_stat_field_triggers_stats_creation(self):
|
||||
"""任一统计字段存在即可触发 MatchStats 创建。"""
|
||||
# 模拟 normalize 后的结果
|
||||
nm = NormalizedMatch(
|
||||
league_type="E0",
|
||||
date=datetime(2026, 1, 15, 15, 0, tzinfo=timezone.utc),
|
||||
home_team="A",
|
||||
away_team="B",
|
||||
match_status="finished",
|
||||
home_goals=1,
|
||||
away_goals=0,
|
||||
# 只有 shots,无 xG
|
||||
home_shots=10,
|
||||
away_shots=5,
|
||||
)
|
||||
# 验证:任一统计字段存在
|
||||
has_stats = (
|
||||
nm.home_xg is not None or nm.away_xg is not None
|
||||
or nm.home_shots is not None or nm.away_shots is not None
|
||||
or nm.home_corners is not None or nm.away_corners is not None
|
||||
or nm.home_possession is not None
|
||||
)
|
||||
assert has_stats is True, "有 shots 时应视为有统计数据"
|
||||
|
||||
def test_no_stats_means_no_match_stats(self):
|
||||
"""没有任何统计字段时不应创建 MatchStats。"""
|
||||
nm = NormalizedMatch(
|
||||
league_type="E0",
|
||||
date=datetime(2026, 1, 15, 15, 0, tzinfo=timezone.utc),
|
||||
home_team="A",
|
||||
away_team="B",
|
||||
match_status="finished",
|
||||
home_goals=1,
|
||||
away_goals=0,
|
||||
)
|
||||
has_stats = (
|
||||
nm.home_xg is not None or nm.away_xg is not None
|
||||
or nm.home_shots is not None or nm.away_shots is not None
|
||||
or nm.home_corners is not None or nm.away_corners is not None
|
||||
or nm.home_possession is not None
|
||||
)
|
||||
assert has_stats is False, "无统计字段时不应创建 MatchStats"
|
||||
Reference in New Issue
Block a user