安全加固 + 数据管线修复 + 质量改进(第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
+107
View File
@@ -0,0 +1,107 @@
"""回归测试: match_stats.available_at 回测防泄漏语义。
验证:
1. _is_stats_available: available_at is None + cutoff 不为 None → 不可用
2. _is_stats_available: available_at is None + cutoff is None(实盘) → 可用(兼容旧数据)
3. _is_stats_available: available_at > cutoff → 不可用
4. _is_stats_available: available_at <= cutoff → 可用
"""
from __future__ import annotations
from datetime import datetime, timezone, timedelta
from unittest.mock import MagicMock
import pytest
from src.llm.context_builder import _is_stats_available
def _make_stats(available_at):
s = MagicMock()
s.available_at = available_at
return s
class TestIsStatsAvailable:
"""_is_stats_available 回测防泄漏语义。"""
def test_none_before_allows_none_available_at(self):
"""实盘模式(before=None): available_at 为 None 时允许(兼容旧数据)。"""
stats = _make_stats(available_at=None)
assert _is_stats_available(stats, before=None) is True
def test_cutoff_with_none_available_at_is_unavailable(self):
"""回测模式(before=cutoff): available_at 为 None → 不可用(保守)。
这是核心修复:防止无血缘时间的后期回填数据进入回测。
"""
stats = _make_stats(available_at=None)
cutoff = datetime(2026, 1, 13, 20, 0, tzinfo=timezone.utc)
assert _is_stats_available(stats, before=cutoff) is False
def test_available_at_after_cutoff_is_unavailable(self):
"""available_at 在 cutoff 之后 → 不可用。"""
cutoff = datetime(2026, 1, 13, 20, 0, tzinfo=timezone.utc)
stats = _make_stats(available_at=cutoff + timedelta(hours=1))
assert _is_stats_available(stats, before=cutoff) is False
def test_available_at_before_cutoff_is_available(self):
"""available_at 在 cutoff 之前 → 可用。"""
cutoff = datetime(2026, 1, 13, 20, 0, tzinfo=timezone.utc)
stats = _make_stats(available_at=cutoff - timedelta(hours=1))
assert _is_stats_available(stats, before=cutoff) is True
def test_available_at_equals_cutoff_is_available(self):
"""available_at == cutoff → 可用(边界包含)。"""
cutoff = datetime(2026, 1, 13, 20, 0, tzinfo=timezone.utc)
stats = _make_stats(available_at=cutoff)
assert _is_stats_available(stats, before=cutoff) is True
def test_real_world_scenario_backtest_avoids_future_data(self):
"""真实场景:回测时,赛后才生成的统计数据不应出现。
比赛:2026-01-15 20:00
cutoff(回测):2026-01-14 20:00(赛前 1 天)
stats 在赛后才生成(available_at=2026-01-15 22:00)
→ 不可用
"""
cutoff = datetime(2026, 1, 14, 20, 0, tzinfo=timezone.utc)
stats = _make_stats(available_at=datetime(2026, 1, 15, 22, 0, tzinfo=timezone.utc))
assert _is_stats_available(stats, before=cutoff) is False
class TestBzzoirotAvailableAt:
"""验证 bzzoiro.py 写入 available_at 使用 match_date 而非 now。"""
def test_bzzoiro_sets_available_at_from_match_date(self):
"""bzzoiro.py 应在创建 stats 时使用 nm.date 作为 available_at。"""
import inspect
from src.data import bzzoiro
source = inspect.getsource(bzzoiro)
# 验证:存在 available_at = nm.date 的逻辑
assert 'available_at = nm.date if nm.date else now' in source, \
"bzzoiro.py 应使用 nm.date 作为 available_at"
def test_bzzoiro_existing_match_uses_match_date(self):
"""bzzoiro.py 更新已有比赛时也应用 nm.date。"""
import inspect
from src.data import bzzoiro
source = inspect.getsource(bzzoiro)
# 验证两处都更新
count = source.count('available_at = nm.date if nm.date else now')
assert count == 2, f"期望 2 处使用 nm.date,实际 {count}"
class TestUnderstatAvailableAt:
"""验证 understat.py 写入 available_at 使用 match_date。"""
def test_understat_sets_available_at_from_match_date(self):
"""understat.py 应使用 existing.match_date 作为 available_at。"""
import inspect
from src.data import understat
source = inspect.getsource(understat)
assert 'available_at = match_date' in source, \
"understat.py 应使用 match_date 作为 available_at"