From 0742eef52ec5355fe057d1325fb9970bff569ac3 Mon Sep 17 00:00:00 2001 From: WorkBuddy Date: Tue, 22 Sep 2026 18:03:20 +0800 Subject: [PATCH] =?UTF-8?q?fix(quality):=20=E8=B4=A8=E9=87=8F=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=E5=BC=95=E7=94=A8=E5=B7=B2=E5=88=A0=E9=99=A4=E7=9A=84?= =?UTF-8?q?=20MatchStats.id=20=E5=BF=85=E7=84=B6=20500=20+=20=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=E6=97=A0=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 生产实测: POST /admin/data-quality/run 100% 失败,且日志无任何记录。 根因链: 1. P0-02 把 MatchStats 主键改为 match_id(无 id 属性),但质量检查 查询仍写 MatchStats.id.is_(None) → AttributeError → 500 2. handler 无 logger 调用;未捕获异常走 uvicorn.error(默认 propagate=False),不经过 root handler → 内存日志页/文件日志 都看不到 traceback,排障无据可查 修复: - MatchStats.id → MatchStats.match_id(与 P0-02 模型对齐) - 成功路径记 info 日志(检查名=通过/未通过(计数)) - setup_logging 打开 uvicorn/uvicorn.error 的 propagate, 未捕获异常 traceback 进入内存缓冲与滚动文件 - 回归测试 3 例(mock session):修复前红(3 failed),修复后绿; 全量 329 passed 8 skipped 零回归 --- src/api/routes/admin_quality.py | 10 ++++- src/core/log_buffer.py | 8 ++++ tests/test_data_quality_run.py | 66 +++++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 tests/test_data_quality_run.py diff --git a/src/api/routes/admin_quality.py b/src/api/routes/admin_quality.py index d2abfdc..53629ae 100644 --- a/src/api/routes/admin_quality.py +++ b/src/api/routes/admin_quality.py @@ -234,13 +234,14 @@ async def run_data_quality_check(db: AsyncSession = Depends(get_db_read)): checks = [] # 检查1: 已完赛但无统计的比赛 + # 注意: MatchStats 主键是 match_id(P0-02),不是 id —— 引用 .id 会 AttributeError finished_no_stats = ( await db.execute( select(func.count()) .select_from(Match) .outerjoin(MatchStats, Match.id == MatchStats.match_id) .where(Match.match_status == "finished") - .where(MatchStats.id.is_(None)) + .where(MatchStats.match_id.is_(None)) ) ).scalar() or 0 @@ -276,6 +277,13 @@ async def run_data_quality_check(db: AsyncSession = Depends(get_db_read)): db.add(c) await db.commit() + # 成功路径留痕:检查何时跑过、各项结果如何(此前 handler 无任何日志, + # 加上未捕获异常走 uvicorn.error 不进内存缓冲,线上排障无据可查) + logger.info( + "数据质量检查完成: %s", + "; ".join(f"{c.check_name}={'通过' if c.passed else '未通过'}({c.actual_value:.0f})" for c in checks), + ) + return {"ok": True, "checks": [{"name": c.check_name, "passed": c.passed} for c in checks]} diff --git a/src/core/log_buffer.py b/src/core/log_buffer.py index b3deda9..bbcc52d 100644 --- a/src/core/log_buffer.py +++ b/src/core/log_buffer.py @@ -80,6 +80,14 @@ def setup_logging(level: str = "INFO", log_file: str = "") -> None: if root.level == logging.NOTSET or root.level > logging.INFO: root.setLevel(getattr(logging, level.upper(), logging.INFO)) + # uvicorn 的 logger 默认 propagate=False:未捕获异常的 traceback 只进 + # stderr,不经过 root 的任何 handler —— Admin 日志页与文件日志都看不到, + # 线上 500 排障无据可查。打开 propagate 让它们进入内存缓冲/滚动文件。 + for uv_name in ("uvicorn", "uvicorn.error"): + uv_logger = logging.getLogger(uv_name) + if not uv_logger.propagate: + uv_logger.propagate = True + if not any(isinstance(h, MemoryLogHandler) for h in root.handlers): handler = MemoryLogHandler() handler.setLevel(logging.INFO) diff --git a/tests/test_data_quality_run.py b/tests/test_data_quality_run.py new file mode 100644 index 0000000..38f7a01 --- /dev/null +++ b/tests/test_data_quality_run.py @@ -0,0 +1,66 @@ +"""数据质量检查回归测试。 + +背景(P0-02 遗留): MatchStats 主键改为 match_id 后,质量检查查询仍引用 +MatchStats.id → AttributeError → POST /admin/data-quality/run 必然 500, +前端显示「运行失败」;且 handler 无日志,未捕获异常走 uvicorn.error +(propagate=False)不进内存缓冲/文件日志,排障时无据可查。 +""" +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +from src.api.routes import admin_quality + + +def _fake_db(scalars: list[int]) -> MagicMock: + """按顺序返回 scalar() 计数的假 AsyncSession。""" + db = MagicMock() + results = [] + for v in scalars: + r = MagicMock() + r.scalar.return_value = v + results.append(r) + db.execute = AsyncMock(side_effect=results) + db.add = MagicMock() + db.commit = AsyncMock() + return db + + +async def test_run_data_quality_check_no_attribute_error(): + """检查查询不得引用 MatchStats.id(P0-02 后该属性不存在)。 + + 修复前: run_data_quality_check 抛 AttributeError → 500。 + """ + db = _fake_db([3, 1]) + + out = await admin_quality.run_data_quality_check(db) + + assert out["ok"] is True + assert out["checks"] == [ + {"name": "finished_without_stats", "passed": False}, + {"name": "league_without_standings", "passed": False}, + ] + # 检查结果落库(2 条 DataQualityCheck) + assert db.add.call_count == 2 + db.commit.assert_awaited_once() + + +async def test_run_data_quality_check_all_passed(): + db = _fake_db([0, 0]) + + out = await admin_quality.run_data_quality_check(db) + + assert out["ok"] is True + assert all(c["passed"] for c in out["checks"]) + + +async def test_run_data_quality_check_logs_summary(caplog): + """成功路径必须留日志:否则线上无从得知检查何时跑过、结果如何。""" + import logging + + db = _fake_db([0, 0]) + + with caplog.at_level(logging.INFO, logger="src.api.routes.admin_quality"): + await admin_quality.run_data_quality_check(db) + + assert any("数据质量检查" in r.message for r in caplog.records) -- 2.39.5