Merge branch 'main' into feat-monitoring-enrich

This commit is contained in:
2026-09-22 20:46:08 +08:00
5 changed files with 118 additions and 4 deletions
+4
View File
@@ -94,6 +94,10 @@ export default function CollectionPage() {
setTaskStatus(job.status === 'success' ? 'done' : 'error') setTaskStatus(job.status === 'success' ? 'done' : 'error')
stopPolling() stopPolling()
loadRecentJobs() // 终态后刷新历史列表 loadRecentJobs() // 终态后刷新历史列表
// 广播采集终态:数据完整性等依赖页立即刷新,不必等轮询周期
window.dispatchEvent(new CustomEvent('profeto:ingest-done', {
detail: { jobId: job.id, status: job.status },
}))
} }
} catch { /* 单次轮询失败不影响后续 */ } } catch { /* 单次轮询失败不影响后续 */ }
} }
+31 -3
View File
@@ -55,6 +55,9 @@ export default function DataCompletenessPage() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [highlightedLeague, setHighlightedLeague] = useState<string | null>(null) const [highlightedLeague, setHighlightedLeague] = useState<string | null>(null)
// 页面文案承诺「每 5 秒自动刷新」,此前并未实现(仅挂载加载一次),
// 采集完成后数字不动 —— 现补齐:5s 轮询 + 采集完成事件即时刷新。
const [autoRefresh, setAutoRefresh] = useState(true)
const leagueRefs = useRef<Record<string, HTMLDivElement | null>>({}) const leagueRefs = useRef<Record<string, HTMLDivElement | null>>({})
const load = useCallback(async () => { const load = useCallback(async () => {
@@ -72,6 +75,20 @@ export default function DataCompletenessPage() {
useEffect(() => { load() }, [load]) useEffect(() => { load() }, [load])
// 5s 自动轮询(与页面文案一致);已有数据时刷新不闪骨架
useEffect(() => {
if (!autoRefresh) return
const t = setInterval(load, 5_000)
return () => clearInterval(t)
}, [autoRefresh, load])
// 采集页任务终态广播 → 立即刷新(不等下一个 5s 周期)
useEffect(() => {
const onIngestDone = () => load()
window.addEventListener('profeto:ingest-done', onIngestDone)
return () => window.removeEventListener('profeto:ingest-done', onIngestDone)
}, [load])
// 点击问题项 → 滚动到对应联赛卡片并高亮 // 点击问题项 → 滚动到对应联赛卡片并高亮
const scrollToLeague = useCallback((code: string) => { const scrollToLeague = useCallback((code: string) => {
setHighlightedLeague(code) setHighlightedLeague(code)
@@ -92,9 +109,20 @@ export default function DataCompletenessPage() {
title="数据完整性" title="数据完整性"
description="按联赛统计 bzzoiro 数据采集覆盖度。每 5 秒自动刷新,或点击右上角按钮手动刷新。" description="按联赛统计 bzzoiro 数据采集覆盖度。每 5 秒自动刷新,或点击右上角按钮手动刷新。"
action={ action={
<button onClick={load} disabled={loading} className="btn-sm btn-outline"> <div className="flex items-center gap-2">
{loading ? <><Spinner /> </> : '刷新'} <label className="flex cursor-pointer items-center gap-1.5 text-2xs text-ink-500">
</button> <input
type="checkbox"
checked={autoRefresh}
onChange={e => setAutoRefresh(e.target.checked)}
className="accent-current"
/>
5s
</label>
<button onClick={load} disabled={loading} className="btn-sm btn-outline">
{loading ? <><Spinner /> </> : '刷新'}
</button>
</div>
} }
/> />
+9 -1
View File
@@ -234,13 +234,14 @@ async def run_data_quality_check(db: AsyncSession = Depends(get_db_read)):
checks = [] checks = []
# 检查1: 已完赛但无统计的比赛 # 检查1: 已完赛但无统计的比赛
# 注意: MatchStats 主键是 match_id(P0-02),不是 id —— 引用 .id 会 AttributeError
finished_no_stats = ( finished_no_stats = (
await db.execute( await db.execute(
select(func.count()) select(func.count())
.select_from(Match) .select_from(Match)
.outerjoin(MatchStats, Match.id == MatchStats.match_id) .outerjoin(MatchStats, Match.id == MatchStats.match_id)
.where(Match.match_status == "finished") .where(Match.match_status == "finished")
.where(MatchStats.id.is_(None)) .where(MatchStats.match_id.is_(None))
) )
).scalar() or 0 ).scalar() or 0
@@ -276,6 +277,13 @@ async def run_data_quality_check(db: AsyncSession = Depends(get_db_read)):
db.add(c) db.add(c)
await db.commit() 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]} return {"ok": True, "checks": [{"name": c.check_name, "passed": c.passed} for c in checks]}
+8
View File
@@ -80,6 +80,14 @@ def setup_logging(level: str = "INFO", log_file: str = "") -> None:
if root.level == logging.NOTSET or root.level > logging.INFO: if root.level == logging.NOTSET or root.level > logging.INFO:
root.setLevel(getattr(logging, level.upper(), 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): if not any(isinstance(h, MemoryLogHandler) for h in root.handlers):
handler = MemoryLogHandler() handler = MemoryLogHandler()
handler.setLevel(logging.INFO) handler.setLevel(logging.INFO)
+66
View File
@@ -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)