安全加固 + 数据管线修复 + 质量改进(第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:
Profeto Agent
2026-09-19 08:02:57 +00:00
parent bee330f31f
commit 6c24672e87
21 changed files with 1580 additions and 87 deletions
+17 -4
View File
@@ -266,8 +266,12 @@ class BzzoiroSource:
db.add(m)
await db.flush()
existing_matches[match_key] = m # 防止同批重复
if nm.home_xg is not None or nm.away_xg is not None:
# 存在任一统计字段即可创建 MatchStats(不再强制要求 xG)
if any(getattr(nm, f) is not None for f in ['home_xg', 'away_xg', 'home_shots', 'away_shots', 'home_shots_on_target', 'away_shots_on_target', 'home_corners', 'away_corners', 'home_possession', 'home_yellow_cards', 'away_yellow_cards', 'home_red_cards', 'away_red_cards']):
now = datetime.now(timezone.utc)
# available_at 语义:统计「可被使用」的最早时间,至少不早于比赛结束
# 局限:用 match_date(开球时间)近似,实际完赛时间约为 +2 小时
available_at = nm.date if nm.date else now
stats = MatchStats(
match_id=m.id,
home_xg=nm.home_xg,
@@ -286,7 +290,7 @@ class BzzoiroSource:
source="bzzoiro",
source_record_id=str(raw.get("id", "")),
retrieved_at=now,
available_at=now,
available_at=available_at,
)
db.add(stats)
league_r["inserted"] += 1
@@ -305,14 +309,23 @@ class BzzoiroSource:
if existing_match.match_stage is None and nm.match_stage:
existing_match.match_stage = nm.match_stage
changed = True
if existing_match.stats is None and (nm.home_xg is not None or nm.away_xg is not None):
# 存在任一统计字段即可创建 MatchStats(不再强制要求 xG)
if existing_match.stats is None and (
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
):
now = datetime.now(timezone.utc)
# available_at 语义:统计「可被使用」的最早时间,至少不早于比赛结束
# 局限:用 match_date(开球时间)近似,实际完赛时间约为 +2 小时
available_at = nm.date if nm.date else now
existing_match.stats = MatchStats(
match_id=existing_match.id,
source="bzzoiro",
source_record_id=str(raw.get("id", "")),
retrieved_at=now,
available_at=now,
available_at=available_at,
)
db.add(existing_match.stats)
await db.flush()