feat(api): 比赛上下文与联赛列表改公开只读(P1-2/P1-3)

- GET /matches/{id}/context 移除 require_admin:公开站详情页「近况/交锋」
  数据来源,匿名 401 会导致前端静默空态;响应结构不变,不触发 LLM
- GET /leagues 改公开只读:公开站联赛筛选动态加载;仅返回
  id/code/name/country 四个展示字段,不含配置或密钥信息
- 前端新增 useLeagues hook:优先请求 /api/v1/leagues,失败/空回退
  本地五大联赛常量(已知代码保留中文标签,新联赛按 API 名称追加)
- 新增 tests/test_public_readonly_api.py(6 项):匿名 200/404、响应形状、
  路由依赖声明检查(matches 路由无 require_admin)+ 判别力守卫
  (ingest 路由必须检出 require_admin,防检测器恒真)
This commit is contained in:
WorkBuddy
2026-09-21 20:55:26 +08:00
parent 3056a95ef4
commit a24017eb69
4 changed files with 211 additions and 7 deletions
+6 -3
View File
@@ -15,13 +15,16 @@ import { fetchMatchDetail, fetchMatchContext } from '../admin/dal'
import type { MatchDetailOut, MatchContextOut } from '../admin/types'
import { useMatchesList } from './matches/hooks/useMatchesList'
import { useMatchPredict } from './matches/hooks/useMatchPredict'
import { useLeagues } from './matches/hooks/useLeagues'
import { PredictModal } from './matches/components/MatchPredictPanel'
import { MatchRow } from './matches/components/MatchDetailSection'
import { Spinner, SkeletonRows, Switch, formatDateHeader, groupByDate, withinNext3Days } from './matches/ui'
import { LEAGUES, type Match } from './matches/types'
import { type Match } from './matches/types'
export default function Matches() {
const [error, setError] = useState<string | null>(null) // 列表与预测共用(拆分前即如此)
// P1-3: 联赛列表优先请求 /api/v1/leagues,失败/空回退本地五大联赛常量
const leagues = useLeagues()
const {
league, setLeague,
status, setStatus,
@@ -58,7 +61,7 @@ export default function Matches() {
}, [])
const scrollToTop = () => window.scrollTo({ top: 0, behavior: 'smooth' })
const leagueName = LEAGUES.find(l => l.code === league)?.name ?? league
const leagueName = leagues.find(l => l.code === league)?.name ?? league
/** 未开赛默认仅展示未来 3 天;其余状态展示全部。showAllUpcoming=true 时展开全部。 */
const isScheduledView = status === 'scheduled'
@@ -91,7 +94,7 @@ export default function Matches() {
<div className="space-y-5">
{/* ── 联赛版面切换 ── */}
<nav className="flex items-center gap-6 overflow-x-auto border-b border-ink-900" aria-label="联赛">
{LEAGUES.map(l => (
{leagues.map(l => (
<button
key={l.code}
onClick={() => setLeague(l.code)}
@@ -0,0 +1,38 @@
/**
* 公开站联赛列表(P1-3):优先请求 GET /api/v1/leagues,
* 失败或返回空数组则回退本地五大联赛常量(LEAGUES)。
*
* 显示名规则:常量里已有的 code 沿用中文标签(保持现有 UI 语言不变),
* 新增联赛用 API 返回的 name;排序按常量顺序优先、新联赛按 API 返回序追加。
* API 仅返回 {id, code, name, country},无敏感配置字段。
*/
import { useEffect, useState } from 'react'
import { fetchLeagues } from '../../../admin/dal'
import { LEAGUES } from '../types'
export function useLeagues(): { code: string; name: string }[] {
const [leagues, setLeagues] = useState(LEAGUES)
useEffect(() => {
let alive = true
;(async () => {
// dal.fetchLeagues 已兜底:网络/权限异常时返回 []
const rows = await fetchLeagues()
if (!alive || rows.length === 0) return
const zhName = new Map(LEAGUES.map(l => [l.code, l.name] as const))
const rank = new Map(LEAGUES.map((l, i) => [l.code, i] as const))
const merged = rows
.slice()
.sort(
(a, b) => (rank.get(a.code) ?? LEAGUES.length) - (rank.get(b.code) ?? LEAGUES.length),
)
.map(l => ({ code: l.code, name: zhName.get(l.code) ?? l.name }))
setLeagues(merged)
})()
return () => {
alive = false
}
}, [])
return leagues
}
+4 -4
View File
@@ -7,7 +7,6 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, or_, select
from sqlalchemy.orm import selectinload
from src.api.deps import require_admin
from src.api.schemas import MatchListOut, MatchOut, PredictionOut
from src.db.base import AsyncSession, get_db_read
from src.db.models import League, Match, Prediction, Standing
@@ -32,8 +31,9 @@ def _stats_dict(stats) -> dict | None:
}
@router.get("/leagues", response_model=list[dict], dependencies=[Depends(require_admin)])
@router.get("/leagues", response_model=list[dict])
async def list_leagues(db: AsyncSession = Depends(get_db_read)):
"""联赛列表(公开只读,P1-3: 公开站联赛筛选需要;仅返回展示字段)。"""
stmt = select(League).order_by(League.name)
result = await db.execute(stmt)
leagues = result.scalars().all()
@@ -190,9 +190,9 @@ async def get_match(match_id: int, db: AsyncSession = Depends(get_db_read)):
)
@router.get("/matches/{match_id}/context", dependencies=[Depends(require_admin)])
@router.get("/matches/{match_id}/context")
async def match_context(match_id: int, db: AsyncSession = Depends(get_db_read)):
"""比赛上下文(只读,不触发 LLM):双方近况 + 历史交锋。
"""比赛上下文(公开只读,P1-2: 公开站详情页需要;不触发 LLM):双方近况 + 历史交锋。
全部基于现有数据聚合:
- recent_home / recent客队:该队最近 5 场已完赛(进球/结果)
+163
View File
@@ -0,0 +1,163 @@
"""公开只读 API 回归(P1-2 / P1-3):context 与 leagues 匿名可访问。
背景:
- 公开站 MatchDetailSection 展开详情会请求 /matches/{id}/context,
此前该端点挂 require_admin,未登录 401 被 fetchMatchContext 的
catch 吞掉 → 近况/交锋静默为空;
- 公开站联赛筛选需要 /leagues,此前同样 require_admin,前端写死五大联赛。
守卫(双保险):
1. 功能层:最小 FastAPI app + dependency_overrides 注入 fake session,
匿名请求(无任何凭据)→ 200;不存在的 match → 404。
(不启动完整 app lifespan,遵循 test_api_critical.py 的既定约束)
2. 鉴权层:检查路由依赖声明,require_admin 不得出现在
/leagues 与 /matches/{match_id}/context。dev 环境 require_admin
未配置鉴权时 fail-open,功能层测不出「加回了 require_admin」的回归,
必须靠本层声明检查;并用 ingest 路由证明检查器本身有判别力
(变异保护:若有人给公开端点加回 require_admin,此处变红)。
"""
from __future__ import annotations
from datetime import datetime, timezone
from fastapi import FastAPI
from fastapi.testclient import TestClient
from src.api.deps import require_admin
from src.api.routes.ingest import router as ingest_router
from src.api.routes.matches import router as matches_router
from src.db.base import get_db_read
from src.db.models import League, Match
# ── fake DB(对齐 routes/matches.py 的实际查询面) ──────────────────
class _FakeResult:
def __init__(self, items):
self._items = list(items)
def scalars(self):
return self
def all(self):
return list(self._items)
def scalar_one_or_none(self):
return self._items[0] if self._items else None
class _FakeDB:
"""match_context 的查询次序:① match 主查询(scalar_one_or_none)
② home_recent ③ away_recent ④ h2h(均 scalars().all())。
League 查询按实体识别直接返回列表(对应 /leagues)。"""
def __init__(self, match=None, match_lists=(), leagues=()):
self._match = match
self._match_lists = list(match_lists)
self._leagues = list(leagues)
self._calls = 0
async def execute(self, stmt):
entity = stmt.column_descriptions[0]["entity"]
if entity is League:
return _FakeResult(self._leagues)
if self._calls == 0:
self._calls += 1
return _FakeResult([self._match] if self._match is not None else [])
idx = self._calls - 1
self._calls += 1
return _FakeResult(self._match_lists[idx] if idx < len(self._match_lists) else [])
def _client(fake_db: _FakeDB) -> TestClient:
app = FastAPI()
app.include_router(matches_router)
app.dependency_overrides[get_db_read] = lambda: fake_db
# 不用 with:不触发 lifespan,无真实 DB 引擎连接
return TestClient(app)
def _league(lid: int, code: str) -> League:
return League(id=lid, code=code, name=f"League {code}", country=f"Country {code}")
def _match(mid: int) -> Match:
return Match(
id=mid,
match_date=datetime(2026, 9, 20, 15, 0, tzinfo=timezone.utc),
home_goals=2,
away_goals=1,
)
# ── 功能层:匿名可访问(P1-2 / P1-3) ──────────────────────────────
def test_leagues_anonymous_200_and_shape():
"""/leagues 匿名 200;仅暴露 id/code/name/country 四字段。"""
db = _FakeDB(leagues=[_league(1, "E0"), _league(2, "SP1")])
r = _client(db).get("/api/v1/leagues")
assert r.status_code == 200
body = r.json()
assert body == [
{"id": 1, "code": "E0", "name": "League E0", "country": "Country E0"},
{"id": 2, "code": "SP1", "name": "League SP1", "country": "Country SP1"},
]
# 不暴露敏感配置字段
assert all(set(item.keys()) == {"id", "code", "name", "country"} for item in body)
def test_context_anonymous_200_empty_data():
"""/context 匿名 200;无数据时三个列表为空(前端空态),结构不变。"""
db = _FakeDB(match=_match(42), match_lists=[[], [], []])
r = _client(db).get("/api/v1/matches/42/context")
assert r.status_code == 200
assert r.json() == {"home_recent": [], "away_recent": [], "h2h": []}
def test_context_anonymous_200_row_shape():
"""/context 行结构与既有前端契约一致(5 字段)。"""
db = _FakeDB(match=_match(42), match_lists=[[_match(1)], [], [_match(2), _match(3)]])
r = _client(db).get("/api/v1/matches/42/context")
assert r.status_code == 200
body = r.json()
assert len(body["home_recent"]) == 1
assert len(body["h2h"]) == 2
assert set(body["h2h"][0].keys()) == {
"match_date", "home_team", "away_team", "home_goals", "away_goals",
}
def test_context_not_found_404():
"""/context 匿名访问不存在的 match → 404(而非 401/503)。"""
db = _FakeDB(match=None)
r = _client(db).get("/api/v1/matches/999/context")
assert r.status_code == 404
# ── 鉴权层:路由依赖声明检查(防 require_admin 回潜) ────────────────
def _admin_paths(router) -> set[str]:
paths: set[str] = set()
for route in router.routes:
for dep in route.dependant.dependencies:
if dep.call is require_admin:
paths.add(route.path)
break
return paths
def test_public_routes_have_no_admin_dependency():
"""/leagues 与 /context 不得挂 require_admin;matches 路由全部公开只读。"""
admin_paths = _admin_paths(matches_router)
assert "/api/v1/leagues" not in admin_paths
assert "/api/v1/matches/{match_id}/context" not in admin_paths
assert admin_paths == set(), f"matches 路由应全部公开只读,仍有 {admin_paths}"
def test_guard_detector_has_discrimination_power():
"""变异保护:检查器必须能在 ingest 路由上发现 require_admin,
否则上一条「无 admin 依赖」断言恒真、毫无判别力。"""
assert "/api/v1/ingest/bzzoiro" in _admin_paths(ingest_router), (
"ingest 路由应仍存在 require_admin 保护;若本断言失败,"
"说明公开只读守卫的检查器已失效,请修复检查逻辑"
)