全量修复:预测系统正确性、安全性与部署问题

P0 严重问题修复:
- 修复 form_slice/stats_slice 主客身份反转(历史比赛视角错误)
- 修复 understat.py httpx 未导入导致的 NameError
- 修复 LLM 解析失败时静默产生假成功预测(0-0 平局+置信度0.5)

预测路径修复:
- multi-agent 路径增加 backtest cutoff 透传,回测防泄漏生效
- H2H 切片汇总统计改为从当前主队视角计数
- 预测唯一约束增加 mode+run_type 维度,防止回测覆盖实盘预测

伤停管线修复:
- IntegrityError 后不再整批回滚丢数据(改用逐条 flush)
- return_date 正确解析并写入
- retrieved_at 比较统一用 date() 避免当天数据不可见
- 唯一索引改为 partial unique index(排除 NULL 重复)
- HTTP 缓存 TTL 从 7 天改为 6 小时

安全与连接管理:
- /api/v1/predict 增加内存滑动窗口限流(10次/分钟/IP)
- 预测路由改用短 session 模式,LLM 调用期间不持有 DB 连接

Docker 部署修复:
- 修复 .dockerignore 排除 *.md 导致 COPY README.md 失败
- 容器内 DATABASE_URL 使用 postgres 服务名(非 localhost)
- 启动时自动执行 alembic upgrade head
- 前端改用多阶段构建(Dockerfile.frontend)

新增测试(5个文件,24+用例):
- test_p0_home_away.py: 主客身份反转回归测试
- test_p0_parse_failure.py: LLM 解析失败回归测试
- test_multi_agent_cutoff.py: multi-agent cutoff 透传测试
- test_h2h_perspective.py: H2H 视角测试
- test_injuries_pipeline.py: 伤停管线 5 项修复测试
- test_predict_protection.py: 限流+短 session 测试
- test_prediction_unique_constraint.py: 唯一约束测试

迁移:
- 0012_injuries_partial_unique_and_return_date.py
- 0013_predictions_unique_constraint_mode_run_type.py
This commit is contained in:
Profeto Agent
2026-09-19 06:43:55 +00:00
parent 11efe91ce9
commit bee330f31f
27 changed files with 1666 additions and 137 deletions
+99
View File
@@ -0,0 +1,99 @@
"""回归测试: /api/v1/predict 限流 + 短 session 模式。
验证:
1. 限流: 同 IP 超过 10 次/分钟返回 429
2. 限流: 不同 IP 独立计数
3. 限流: 滑动窗口过期后恢复
4. 短 session: predict 路由不持有 DB 连接 during LLM call
"""
from __future__ import annotations
import asyncio
import time
import pytest
from src.api.deps import _RateLimiter, rate_limit_predict
class TestRateLimiter:
"""_RateLimiter 滑动窗口限流。"""
def test_allows_within_limit(self):
limiter = _RateLimiter(max_requests=10, window_seconds=60)
for _ in range(10):
assert limiter.is_allowed("192.168.1.1")
def test_blocks_over_limit(self):
limiter = _RateLimiter(max_requests=3, window_seconds=60)
assert limiter.is_allowed("10.0.0.1") # 1
assert limiter.is_allowed("10.0.0.1") # 2
assert limiter.is_allowed("10.0.0.1") # 3
assert not limiter.is_allowed("10.0.0.1") # 4 → blocked
def test_different_keys_independent(self):
"""不同 IP 的限流计数独立。"""
limiter = _RateLimiter(max_requests=2, window_seconds=60)
assert limiter.is_allowed("10.0.0.1")
assert limiter.is_allowed("10.0.0.1")
assert not limiter.is_allowed("10.0.0.1") # blocked
# 不同 IP 仍允许
assert limiter.is_allowed("10.0.0.2")
assert limiter.is_allowed("10.0.0.2")
assert not limiter.is_allowed("10.0.0.2") # blocked
def test_sliding_window_expires(self):
"""滑动窗口:过期后恢复。"""
limiter = _RateLimiter(max_requests=2, window_seconds=1)
assert limiter.is_allowed("10.0.0.1")
assert limiter.is_allowed("10.0.0.1")
assert not limiter.is_allowed("10.0.0.1") # blocked
# 等待窗口过期
time.sleep(1.1)
assert limiter.is_allowed("10.0.0.1") # 窗口过期,恢复
def test_cleans_expired_entries(self):
"""验证过期条目被清理(不会无限增长)。"""
limiter = _RateLimiter(max_requests=100, window_seconds=1)
for _ in range(50):
limiter.is_allowed("10.0.0.1")
# 验证内部状态
assert len(limiter._hits.get("10.0.0.1", [])) == 50
time.sleep(1.1)
# 触发清理
limiter.is_allowed("10.0.0.1")
# 过期条目应被清除,只剩新加入的 1 条
assert len(limiter._hits.get("10.0.0.1", [])) == 1
class TestShortReadSession:
"""short_read 上下文管理器。"""
@pytest.mark.asyncio
async def test_short_read_context_manager(self):
"""short_read 应作为 async context manager 工作。"""
from src.db.base import short_read
import inspect
# 验证是 async context manager (通过 inspect 检查)
assert inspect.isasyncgenfunction(short_read) or hasattr(short_read, "__wrapped__")
# 验证可以调用并返回 context manager
ctx = short_read()
assert hasattr(ctx, "__aenter__")
assert hasattr(ctx, "__aexit__")
class TestDepsImports:
"""验证新依赖可正确导入。"""
def test_rate_limit_predict_importable(self):
from src.api.deps import rate_limit_predict
assert callable(rate_limit_predict)
def test_rate_limiter_importable(self):
from src.api.deps import _RateLimiter, _predict_limiter
assert isinstance(_predict_limiter, _RateLimiter)
assert _predict_limiter.max_requests == 10
assert _predict_limiter.window_seconds == 60