fix:批量修复了一些问题

This commit is contained in:
shangfangjian
2026-09-19 22:51:35 +08:00
parent 835d7217d0
commit 8e6ad5394e
44 changed files with 4921 additions and 395 deletions
+15
View File
@@ -1,9 +1,24 @@
# ---- 应用 ----
# 运行环境: development | production
# production 启动时会强制校验:SECRET_KEY 非空且非弱值、鉴权已配置、DB 弱密码阻断。
APP_ENV=development
LOG_LEVEL=INFO
API_PORT=8000
FRONTEND_PORT=3000
# ---- 生产环境启动必填(缺少则拒绝启动) ----
# 1. SECRET_KEY: 加密主密钥与会话签名根密钥。
# 生成: openssl rand -base64 32
# 严禁使用 changeme/secret/123456 等弱值;变更后已加密配置无法解密。
SECRET_KEY=
# 2. 管理鉴权(至少一项):
# - ADMIN_PASSWORD: 后台登录初始密码(启动后自动 scrypt 哈希入库,之后在「系统配置」页修改)
# - ADMIN_API_KEY: 脚本直连接口用的密钥(请求头 X-API-Key)
# 两者都留空 = 不启用鉴权(仅本地开发)。
ADMIN_PASSWORD=
ADMIN_API_KEY=
# ---- 数据库 ----
POSTGRES_USER=football
POSTGRES_PASSWORD=football
+5 -5
View File
@@ -8,11 +8,11 @@ ENV PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
# P2-5: 创建非 root 用户(容器安全最佳实践)
RUN groupadd --system profeto && useradd --system --gid profeto profeto
RUN pip install --no-cache-dir hatchling
# Fix 1: 不再复制 README.md(.dockerignore 排除了 *.md)
COPY pyproject.toml ./
COPY src ./src
# 可复现构建:先安装锁定版本的依赖(含哈希校验),再安装本项目
COPY pyproject.toml requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY src ./src
RUN pip install --no-cache-dir .
# 将工作目录所有权移交给非 root 用户
@@ -23,5 +23,5 @@ EXPOSE 8000
# 以非 root 用户运行
USER profeto
# Fix 3: 启动时先跑迁移,再启 uvicorn
# 启动时先跑迁移,再启 uvicorn
CMD ["sh", "-c", "alembic upgrade head && uvicorn src.api.app:app --host 0.0.0.0 --port 8000"]
+6
View File
@@ -29,6 +29,12 @@ services:
# 必须覆盖 .env 中的 DATABASE_URL,因为 Settings 不读 DB_HOST/DB_PORT
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:?POSTGRES_USER 未设置}:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD 未设置}@postgres:5432/${POSTGRES_DB:-football}
env_file: .env
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:8000/health/ready || exit 1"]
interval: 10s
timeout: 5s
retries: 3
start_period: 10s
depends_on:
postgres:
condition: service_healthy
+13
View File
@@ -129,6 +129,19 @@ agents/
迭代 prompt 时:复制 `h2h_v1.md``h2h_v2.md`,改内容,传 `prompt_version: "v2"`
`predictions.prompt_version` 存的是 `multi_v2`,与 single 模式的 `v1`/`v2` 天然分组,可在 eval summary 中 A/B 对比。
### 版本纪律(强制)
> **改 prompt 内容必须 bump 版本号(v2→v3),禁止默默修改 `*_v1.md` 内容却不改版本。**
原因:
1. **可复现性**:`predictions.prompt_version` 决定哪份 prompt 产生了历史预测;篡改 v1 会让历史预测的 prompt 来源失真,eval 对比失效。
2. **A/B 可信度**:`get_eval_summary``(provider, model, prompt_version)` 分组。若 v1 内容在不同时间指向不同 prompt,则 v1 桶内数据不可比。
3. **缓存一致性**:模板内容 hash 写入缓存键(`_prompt_template_hash`),版本不变则 hash 不变,命中旧缓存。bump 版本自动让旧缓存失效。
**流程**:改 prompt → 新建 `*_v{N+1}.md` → 新请求传 `prompt_version=v{N+1}` → 旧版本文件保持不变(供历史复现)。
代码保证:写入 DB 的 `prompt_version``_load_prompt_template(version)` / `load_agent_prompt(name, version)` 加载的文件**严格一致**,不会漂移。
## 如何新增一个专家 Agent
三步:
+54 -3
View File
@@ -162,9 +162,60 @@ cat backup.sql | docker exec -i profeto-postgres psql -U football football
## 监控
- `/health`: 存活检查
- 日志:容器 stdout(`docker compose logs -f api`)
- 评估汇总:`GET /api/v1/eval/summary`(准确率/RMSAE/校准度)
### 健康检查
| 端点 | 含义 | HTTP 状态码 |
|---|---|---|
| `/health` | 存活检查(liveness) | 始终 200(进程在跑即活) |
| `/health/ready` | 就绪检查(readiness) | DB 可达 200,不可达 **503** |
### 探针配置
#### Docker Compose
`docker-compose.yml` 已为 `api` 服务配置 readiness:
```yaml
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:8000/health/ready || exit 1"]
interval: 10s
timeout: 5s
retries: 3
start_period: 10s
```
**要点**:必须指向 `/health/ready` 而非 `/health`——后者始终 200,在数据库故障时仍会接收流量,导致请求全部失败。
#### Kubernetes
```yaml
livenessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 5
periodSeconds: 15
readinessProbe:
httpGet:
path: /health/ready
port: 8000
initialDelaySeconds: 10
periodSeconds: 10
failureThreshold: 3
```
**两探针必须区分**:
- `livenessProbe``/health`:仅在进程死锁/崩溃时重启,避免误杀。
- `readinessProbe``/health/ready`:DB 不可用时停止转发流量,恢复后自动切回。
#### 验证
```bash
# 宿主机直接运行(经本地 8000 端口)
python3 tests/test_health_ready.py
```
### 评估
## 安全与限流
+45 -7
View File
@@ -2,32 +2,70 @@
## 本地开发环境搭建
### 锁定依赖策略
项目使用 [pip-tools](https://github.com/jazzband/pip-tools) 锁定依赖版本,确保本地、CI、Docker 三端一致:
| 文件 | 用途 | 生成命令 |
|---|---|---|
| `requirements.txt` | 生产依赖锁定(含 SHA256 哈希) | `pip-compile pyproject.toml --generate-hashes` |
| `requirements-dev.txt` | 开发+CI 依赖锁定(含哈希) | `pip-compile pyproject.toml --extra dev --generate-hashes` |
### 安装步骤
```bash
# 1. 克隆并进入项目
cd Profeto
# 2. 安装依赖(含 dev)
pip install -e ".[dev]"
# 2. 创建虚拟环境
python3.11 -m venv .venv
source .venv/bin/activate
# 3. 启动 PostgreSQL
# 3. 安装 pip-tools(用于同步锁定依赖)
pip install pip-tools
# 4. 同步生产+开发依赖到当前环境(严格按 lock 文件版本,含哈希校验)
pip-sync requirements-dev.txt
# 5. 启动 PostgreSQL(或在 .env 配置外部库)
docker run -d --name profeto-pg \
-e POSTGRES_USER=football -e POSTGRES_PASSWORD=football -e POSTGRES_DB=football \
-p 5432:5432 postgres:16-alpine
# 4. 配置环境变量
# 6. 配置环境变量
cp .env.example .env
# 编辑 .env 填 LLM_API_KEY / BZZOIRO_KEY
# 5. 建表
# 7. 建表
alembic upgrade head
# 6. 启动 API(热重载)
# 8. 启动 API(热重载)
uvicorn src.api.app:app --reload
# 7. 启动前端(另一个终端)
# 9. 启动前端(另一个终端)
cd frontend && npm install && npm run dev
```
> **注意**:不要用 `pip install -e ".[dev]"` 直接安装——它按 pyproject 下界约束解析,版本可能与 lock 文件不一致。统一用 `pip-sync requirements-dev.txt` 保证三端一致。
### 变更依赖时
```bash
# 1. 编辑 pyproject.toml(调整依赖或版本约束)
# 2. 重新生成 lock 文件(含哈希)
pip-compile pyproject.toml --generate-hashes --output-file=requirements.txt --index-url=https://pypi.tuna.tsinghua.edu.cn/simple
pip-compile pyproject.toml --extra dev --generate-hashes --output-file=requirements-dev.txt --index-url=https://pypi.tuna.tsinghua.edu.cn/simple
# 3. 同步到本地环境
pip-sync requirements-dev.txt
# 4. 提交 lock 文件
git add requirements.txt requirements-dev.txt pyproject.toml
```
> **禁止无故大升级主版本依赖**:仅升级真正需要的包,并重新跑全量测试。
## 项目结构
```
+38 -11
View File
@@ -1,13 +1,15 @@
/**
* 主应用入口
*
* 整合前台(报纸风格)和后台(暗色管理)的路由。
* - / → 先知(Profeto)主站
* - /admin/* → 管理后台
* 顶层三分区导航:
* - 比赛/预测 → 公开,报纸风赛程 + 预测
* - 评估 → 只读(后端需 admin 鉴权,未登录引导登录)
* - 管理 → 采集/回测/配置等(需登录)
*/
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { ErrorBoundary } from './components/ErrorBoundary'
import { useState } from 'react'
import Matches from './pages/Matches'
import { adminRoutes } from './admin/routes'
@@ -21,10 +23,34 @@ function dateLine(): string {
})
}
/** 顶层导航三分区 */
type TopView = 'matches' | 'eval' | 'admin'
function TopNav({ onNavigate }: { onNavigate: (v: TopView) => void }) {
const go = (v: TopView) => {
onNavigate(v)
const path = v === 'matches' ? '/' : v === 'eval' ? '/admin/eval' : '/admin'
window.location.assign(path)
}
return (
<nav className="flex items-center justify-center gap-1 border-b border-ink-200" aria-label="主导航">
<button onClick={() => go('matches')} className="tab">
<span aria-hidden="true"></span> /
</button>
<button onClick={() => go('eval')} className="tab">
<span aria-hidden="true"></span>
</button>
<button onClick={() => go('admin')} className="tab">
<span aria-hidden="true"></span>
</button>
</nav>
)
}
function HomePage() {
return (
<div className="min-h-screen bg-paper-50">
{/* ── 报头:粗线 + 居中刊名 + 报眉 ── */}
{/* ── 报头:粗线 + 居中刊名 + 顶层导航 ── */}
<header className="masthead-rule">
<div className="mx-auto max-w-5xl px-5 sm:px-8">
<div className="border-b border-ink-900 py-5 text-center sm:py-6">
@@ -40,14 +66,8 @@ function HomePage() {
</div>
<div className="flex items-center justify-between border-b border-ink-200 py-2 text-2xs text-ink-500">
<span>{dateLine()}</span>
<a
href="/admin"
className="flex items-center gap-1.5 text-press hover:text-press-dark transition-colors"
>
<span aria-hidden="true"></span>
</a>
</div>
<TopNav onNavigate={() => {}} />
</div>
</header>
@@ -64,12 +84,18 @@ function HomePage() {
)
}
/** 管理入口页:直接导向 /admin,由 AdminLayout 处理鉴权(未登录显示登录页) */
function AdminEntry() {
return <Navigate to="/admin" replace />
}
export default function App() {
return (
<ErrorBoundary>
<BrowserRouter>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/admin" element={<AdminEntry />} />
{adminRoutes.map(route => (
<Route key={route.path} path={route.path} element={route.element}>
{route.children.map(child => (
@@ -88,3 +114,4 @@ export default function App() {
</ErrorBoundary>
)
}
+8 -5
View File
@@ -12,16 +12,18 @@ import { fetchHealth } from './dal'
import Login from './Login'
const NAV_ITEMS = [
// ── 观测(只读) ──
{ to: '/admin', label: '仪表盘', icon: '◇', end: true },
{ to: '/admin/collection', label: '数据采集', icon: '◈' },
{ to: '/admin/eval', label: '评估', icon: '◈' },
{ to: '/admin/monitoring', label: '监控', icon: '◐' },
{ to: '/admin/logs', label: '日志', icon: '▤' },
// ── 操作(写入,需登录) ──
{ to: '/admin/predictions', label: '预测管理', icon: '◆' },
{ to: '/admin/backtest', label: '回测管理', icon: '' },
{ to: '/admin/monitoring', label: '监控面板', icon: '' },
{ to: '/admin/collection', label: '数据采集', icon: '' },
{ to: '/admin/backtest', label: '回测', icon: '' },
{ to: '/admin/data-sources', label: '数据源', icon: '◫' },
{ to: '/admin/llm-config', label: 'LLM 配置', icon: '◬' },
{ to: '/admin/config', label: '系统配置', icon: '◑' },
{ to: '/admin/logs', label: '系统日志', icon: '▤' },
{ to: '/admin/eval', label: '评估管理', icon: '◈' },
]
/** 报眉日期行,与前台同款式 */
@@ -220,6 +222,7 @@ export default function AdminLayout() {
<button
onClick={handleLogout}
className="text-ink-500 transition-colors hover:text-press"
title="退出登录"
>
</button>
+12 -9
View File
@@ -25,7 +25,7 @@ export class ApiError extends Error {
async function request<T>(
path: string,
options: RequestInit & { timeoutMs?: number } = {},
options: RequestInit & { timeoutMs?: number; skipAuthHandling?: boolean } = {},
): Promise<T> {
// 修复: 正确拼接 API_BASE
const url = path.startsWith('http')
@@ -34,7 +34,7 @@ async function request<T>(
? path // 已经是绝对路径(如 /health)
: `${API_BASE}${path}`
const { timeoutMs = TIMEOUT_MS, ...fetchOptions } = options
const { timeoutMs = TIMEOUT_MS, skipAuthHandling, ...fetchOptions } = options
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
@@ -59,7 +59,8 @@ async function request<T>(
detail && typeof detail === 'object' && 'detail' in detail
? String((detail as { detail: unknown }).detail)
: `HTTP ${res.status}: ${res.statusText}`
if (res.status === 401) {
// 401 仅在没有显式跳过时广播未登录事件(改密接口的 401 表示当前密码错误,非会话过期)
if (res.status === 401 && !skipAuthHandling) {
message += '\n登录已过期,请重新登录。'
window.dispatchEvent(new CustomEvent(UNAUTHORIZED_EVENT))
}
@@ -88,9 +89,9 @@ async function request<T>(
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body?: unknown, opts?: { timeoutMs?: number }) =>
post: <T>(path: string, body?: unknown, opts?: { timeoutMs?: number; skipAuthHandling?: boolean }) =>
request<T>(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined, ...opts }),
put: <T>(path: string, body?: unknown, opts?: { timeoutMs?: number }) =>
put: <T>(path: string, body?: unknown, opts?: { timeoutMs?: number; skipAuthHandling?: boolean }) =>
request<T>(path, { method: 'PUT', body: body ? JSON.stringify(body) : undefined, ...opts }),
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
}
@@ -118,10 +119,12 @@ export function fetchAuthState(): Promise<{
/** 修改管理员密码(成功后所有会话失效,需重新登录) */
export function changePassword(currentPassword: string, newPassword: string): Promise<{ ok: boolean; message: string }> {
return api.post(`${API_BASE}/auth/change-password`, {
current_password: currentPassword,
new_password: newPassword,
})
// skipAuthHandling: 改密接口的 401 表示「当前密码错误」,非会话过期,不要触发登出
return api.post(
`${API_BASE}/auth/change-password`,
{ current_password: currentPassword, new_password: newPassword },
{ skipAuthHandling: true },
)
}
export { API_BASE }
+56 -7
View File
@@ -8,6 +8,8 @@
import { ReactNode } from 'react'
import { ApiError } from './api'
// ── 卡片 ────────────────────────────────────────────────────────
export function Card({
@@ -271,7 +273,7 @@ export function Alert({
message,
onClose,
}: {
kind: 'error' | 'ok' | 'info'
kind: 'error' | 'ok' | 'info' | 'warning'
title: string
message?: string
onClose?: () => void
@@ -279,17 +281,19 @@ export function Alert({
const style =
kind === 'error'
? 'border-press bg-press-wash'
: kind === 'ok'
? 'border-ink-900 bg-paper-100'
: 'border-ink-300 bg-paper-50'
const titleCls = kind === 'error' ? 'text-press' : 'text-ink-900'
: kind === 'warning'
? 'border-press bg-press-wash/60'
: kind === 'ok'
? 'border-ink-900 bg-paper-100'
: 'border-ink-300 bg-paper-50'
const titleCls = kind === 'error' || kind === 'warning' ? 'text-press' : 'text-ink-900'
return (
<div className={`flex items-start justify-between gap-3 border px-4 py-3 ${style}`}>
<div>
<p className={`flex items-center gap-1.5 text-sm font-medium ${titleCls}`}>
<span
className={`inline-block h-1.5 w-1.5 ${kind === 'error' ? 'bg-press' : 'bg-ink-900'}`}
className={`inline-block h-1.5 w-1.5 ${kind === 'error' || kind === 'warning' ? 'bg-press' : 'bg-ink-900'}`}
aria-hidden="true"
/>
{title}
@@ -315,7 +319,52 @@ export function Alert({
)
}
// ── 加载指示:同前台 Spinner ────────────────────────────────────
// ── 错误横幅:标准化错误标题/文案/建议动作 ──────────────────────
/** 根据错误对象生成标准化的错误标题、文案与建议动作。 */
export function describeError(err: unknown): { title: string; detail: string; kind: 'error' | 'warning' } {
if (err instanceof ApiError) {
const status = err.status
const apiDetail = typeof err.data === 'object' && err.data && 'detail' in (err.data as object)
? String((err.data as { detail: unknown }).detail)
: ''
const msg = apiDetail || err.message
switch (status) {
case 401:
return { title: '登录已过期', detail: '请重新登录后继续操作。', kind: 'warning' }
case 403:
return { title: '无权访问', detail: msg || '当前账号没有执行该操作的权限。', kind: 'error' }
case 429:
return { title: '请求过于频繁', detail: msg || '每分钟最多 10 次预测,请稍后再试。', kind: 'warning' }
case 502:
return { title: '上游 LLM 不可用', detail: msg || 'LLM 服务暂时不可用,请稍后重试或切换到更便宜的模型。', kind: 'error' }
case 503:
return { title: '服务未就绪', detail: msg || '服务器鉴权未配置,请联系管理员。', kind: 'error' }
case 0:
return { title: '网络错误或请求超时', detail: '请检查网络连接后重试。', kind: 'warning' }
}
if (status >= 500) {
return { title: '服务器错误', detail: msg || `HTTP ${status},请稍后重试。`, kind: 'error' }
}
return { title: '请求失败', detail: msg || `HTTP ${status}`, kind: 'error' }
}
if (err instanceof Error) {
return { title: '操作失败', detail: err.message, kind: 'error' }
}
return { title: '未知错误', detail: String(err), kind: 'error' }
}
/** 统一错误横幅:用于页面级错误展示。 */
export function ErrorBanner({
err,
onClose,
}: {
err: unknown
onClose?: () => void
}) {
const { title, detail, kind } = describeError(err)
return <Alert kind={kind} title={title} message={detail} onClose={onClose} />
}
export function Spinner({ className = '' }: { className?: string }) {
return (
+32
View File
@@ -20,6 +20,10 @@ import type {
DataSourceTestResult,
LLMAgentConfig,
LogEntry,
IngestSourceStatus,
MatchDetailOut,
MatchContextOut,
AdminStats,
} from './types'
// ── 仪表盘 ──────────────────────────────────────────────────────
@@ -307,3 +311,31 @@ export async function fetchSystemConfig(): Promise<any[]> {
return []
}
}
/**
* 数据源健康/最近采集状态(只读,不触发采集)
*/
export function fetchIngestStatus(): Promise<{ sources: IngestSourceStatus[] }> {
return api.get<{ sources: IngestSourceStatus[] }>(`${API_BASE}/admin/ingest/status`)
}
/**
* 比赛详情(含最近预测摘要)
*/
export function fetchMatchDetail(id: number): Promise<MatchDetailOut> {
return api.get<MatchDetailOut>(`${API_BASE}/matches/${id}`)
}
/**
* 比赛上下文(双方近况 + 历史交锋,只读)
*/
export function fetchMatchContext(id: number): Promise<MatchContextOut> {
return api.get<MatchContextOut>(`${API_BASE}/matches/${id}/context`)
}
/**
* 管理区统计(只读):近 24h/7d 预测次数
*/
export function fetchAdminStats(): Promise<AdminStats> {
return api.get<AdminStats>(`${API_BASE}/admin/stats`)
}
+71 -10
View File
@@ -12,7 +12,7 @@
import { useEffect, useState } from 'react'
import { triggerBacktest, fetchEvalSummary, fetchLeagues } from '../dal'
import type { BacktestRequest, EvalSummary, League } from '../types'
import type { BacktestRequest, BacktestSummary, EvalSummary, League } from '../types'
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
import TeamSideTag from '../../components/TeamSideTag'
@@ -34,18 +34,43 @@ interface BacktestResultRow {
}
interface BacktestResponse {
summary: {
total: number
scored: number
accuracy_1x2?: number
avg_score_rmse?: number
avg_subjective_confidence?: number
}
summary: BacktestSummary
results: BacktestResultRow[]
}
const OUTCOME_LABEL: Record<string, string> = { '1': '主胜', X: '平局', '2': '客胜' }
/** 导出回测明细为 CSV(UTF-8 BOM,Excel 可直接打开) */
function exportCsv(rows: BacktestResultRow[]) {
const header = [
"比赛日期", "联赛", "主队", "客队", "实际比分", "实际1X2",
"预测主球", "预测客球", "预测1X2", "主观置信度", "1X2命中",
]
const lines = [header.join(",")]
for (const r of rows) {
lines.push([
fmtDate(r.match_date), r.league_code ?? "",
csvCell(r.home_team_zh || r.home_team), csvCell(r.away_team_zh || r.away_team),
r.actual_score, r.actual_1x2 ?? "",
r.pred_home ?? "", r.pred_away ?? "", r.pred_1x2 ?? "",
r.subjective_confidence != null ? String(Math.round(r.subjective_confidence * 100)) : "",
r.correct_1x2 ? "是" : "否",
].join(","))
}
const blob = new Blob(["" + lines.join("\n")], { type: "text/csv;charset=utf-8" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = `backtest_${new Date().toISOString().slice(0, 10)}.csv`
a.click()
URL.revokeObjectURL(url)
}
/** CSV 字段转义:含逗号/引号/换行时加引号 */
function csvCell(v: string): string {
return /[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v
}
function fmtDate(s?: string | null): string {
if (!s) return '—'
return s.slice(0, 10)
@@ -58,6 +83,7 @@ export default function BacktestPage() {
const [dateTo, setDateTo] = useState('')
const [limit, setLimit] = useState(20)
const [mode, setMode] = useState<'single' | 'multi'>('single')
const [model, setModel] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [result, setResult] = useState<BacktestResponse | null>(null)
@@ -90,6 +116,7 @@ export default function BacktestPage() {
date_to: dateTo || undefined,
limit,
mode,
model: model.trim() || undefined,
}
const res = await triggerBacktest(req as BacktestRequest)
setResult(res as unknown as BacktestResponse)
@@ -177,6 +204,19 @@ export default function BacktestPage() {
</div>
</div>
<div>
<label className="mb-1.5 block text-xs text-ink-500">
(,= <code className="font-mono">gpt-4o</code>)
</label>
<input
type="text"
value={model}
onChange={e => setModel(e.target.value)}
placeholder="如 deepseek-chat / 留空使用默认"
className="field w-full"
/>
</div>
{error && <Alert kind="error" title="回测失败" message={error} onClose={() => setError(null)} />}
<button type="submit" disabled={loading} className="btn btn-solid w-full">
@@ -190,15 +230,31 @@ export default function BacktestPage() {
<div className="space-y-6">
{summary && (
<Card>
<CardHeader title="回测结果" />
<CardHeader
title="回测结果"
description={`模式: ${mode}${model ? ` · 模型: ${model}` : ''} · 限 ${limit}`}
action={
result?.results?.length
? (<button onClick={() => exportCsv(result.results)} className="btn btn-sm"> CSV</button>)
: undefined
}
/>
<CardBody>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
<div>
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">
{summary.scored}/{summary.total}
</div>
<div className="mt-1 text-2xs text-ink-400"> / </div>
</div>
<div>
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">
{summary.success}/{summary.degraded}
</div>
<div className="mt-1 text-2xs text-ink-400">
/ <span className="text-ink-300"> (degraded)</span>
</div>
</div>
<div>
<div className="font-serif text-2xl font-bold tabular-nums text-press">
{summary.accuracy_1x2 !== undefined && summary.accuracy_1x2 !== null
@@ -223,6 +279,11 @@ export default function BacktestPage() {
</div>
<div className="mt-1 text-2xs text-ink-400"></div>
</div>
{summary.degraded > 0 && (
<div className="col-span-full border-l-2 border-press bg-press-wash/40 px-3 py-2 text-2xs leading-relaxed text-press-dark">
{summary.degraded} (),
</div>
)}
</div>
</CardBody>
</Card>
+107 -46
View File
@@ -1,20 +1,13 @@
/**
* Admin 后台 - 数据源管理页面(报刊风)
*
* 功能:
* - 显示各数据源配置状态(脱敏),标明值来源:DB 覆盖 / .env 默认 / 未配置
* - 在线修改数据源 API Key(写入 app_settings,覆盖 .env;清除则回落)
* - 测试连接按钮(后端真实请求上游一次,不触发入库)
*/
import { useEffect, useState, useCallback } from 'react'
import {
fetchDataSourceStatuses,
fetchIngestStatus,
fetchAdminStats,
updateSetting,
clearSetting,
testDataSourceConnection,
} from '../dal'
import type { DataSourceStatus, DataSourceTestResult } from '../types'
import type { DataSourceStatus, DataSourceTestResult, IngestSourceStatus, AdminStats } from '../types'
import SettingRow from '../SettingRow'
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components'
@@ -31,6 +24,9 @@ export default function DataSourcesPage() {
const [sources, setSources] = useState<DataSourceStatus[]>([])
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState('')
const [ingestStats, setIngestStats] = useState<Record<string, IngestSourceStatus>>({})
const [ingestLoading, setIngestLoading] = useState(true)
const [stats, setStats] = useState<AdminStats | null>(null)
const [testingSource, setTestingSource] = useState<string | null>(null)
const [testResults, setTestResults] = useState<Record<string, DataSourceTestResult>>({})
@@ -52,16 +48,41 @@ export default function DataSourcesPage() {
}
}, [])
// 健康状态(只读,与配置加载并行;失败不阻塞配置页)
const loadIngest = useCallback(async () => {
setIngestLoading(true)
try {
const { sources } = await fetchIngestStatus()
setIngestStats(Object.fromEntries(sources.map(x => [x.name, x])))
} catch {
/* ignore */
} finally {
setIngestLoading(false)
}
}, [])
// 管理区统计(只读)
const loadStats = useCallback(async () => {
try {
setStats(await fetchAdminStats())
} catch {
/* ignore */
}
}, [])
useEffect(() => {
loadSources()
}, [loadSources])
loadIngest()
loadStats()
}, [loadSources, loadIngest, loadStats])
async function handleTest(sourceName: string) {
setTestingSource(sourceName)
setTestResults(prev => ({ ...prev, [sourceName]: { ok: false, status: null, latency_ms: 0, detail: '测试中...' } }))
try {
const result = await testDataSourceConnection(sourceName)
setTestResults(prev => ({ ...prev, [sourceName]: result }))
} catch (err) {
} catch (err: unknown) {
const msg = err instanceof Error ? err.message.split('\n')[0] : '连接失败'
setTestResults(prev => ({ ...prev, [sourceName]: { ok: false, status: null, latency_ms: 0, detail: msg } }))
} finally {
@@ -69,6 +90,47 @@ export default function DataSourcesPage() {
}
}
// 渲染数据源健康块(最近采集 + 异常提示)
function renderHealth(sourceName: string) {
const st = ingestStats[sourceName]
if (!st) {
return (
<div className="flex items-center justify-between text-xs">
<span className="text-ink-400"></span>
<span className="text-ink-400">{ingestLoading ? '加载中...' : '暂无数据'}</span>
</div>
)
}
const ago = st.last_success_at ? formatTime(st.last_success_at) : '暂无记录'
const issues: string[] = []
if (st.status === 'key_not_configured') issues.push('未配置 API Key')
else if (st.status === 'no_data') issues.push('本地无数据,建议补采')
if (st.last_failure) issues.push('近期有采集失败')
return (
<div className="space-y-1.5 border-t border-ink-200 pt-3">
<div className="flex items-center justify-between text-xs">
<span className="text-ink-400"></span>
<span className="text-ink-600">{ago}</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-ink-400">()</span>
<span className="text-ink-600">{st.recent_count.toLocaleString()} </span>
</div>
{st.note && <p className="text-2xs leading-relaxed text-ink-400">{st.note}</p>}
{issues.length > 0 && (
<p className="border-l-2 border-press bg-press-wash/40 px-2 py-1 text-2xs leading-relaxed text-press-dark">
{issues.join(' / ')}
</p>
)}
{st.last_failure && (
<p className="truncate text-2xs text-ink-400" title={st.last_failure.detail}>
: {st.last_failure.detail.slice(0, 60)}
</p>
)}
</div>
)
}
async function handleSave(key: string, value: string) {
setBusyKey(key)
setRowNotice(null)
@@ -98,11 +160,12 @@ export default function DataSourcesPage() {
}
}
return (
<div className="space-y-6">
<SectionHeader
title="数据源管理"
description="数据采集源的 API 配置与连通性测试。修改保存到数据库并立即生效,无需重启;「回落 .env」删除覆盖值。"
description="数据采集源的 API 配置、健康状态与连通性测试。"
/>
{loadError && (
@@ -132,7 +195,6 @@ export default function DataSourcesPage() {
return (
<Card key={source.name}>
<CardBody className="space-y-4">
{/* 头部 */}
<div className="flex flex-wrap items-center justify-between gap-x-2 gap-y-1 border-b border-ink-200 pb-3">
<h3 className="font-serif text-sm font-bold text-ink-900">{source.label}</h3>
<Badge status={source.key_configured ? 'success' : 'error'}>
@@ -142,7 +204,6 @@ export default function DataSourcesPage() {
<p className="text-2xs leading-relaxed text-ink-500">{source.description}</p>
{/* 配置项 */}
{source.settings.length > 0 ? (
<div>
{source.settings.map(setting => (
@@ -151,10 +212,7 @@ export default function DataSourcesPage() {
setting={setting}
editing={editingKey === setting.key}
busy={busyKey === setting.key}
onEdit={() => {
setEditingKey(setting.key)
setRowNotice(null)
}}
onEdit={() => { setEditingKey(setting.key); setRowNotice(null) }}
onCancel={() => setEditingKey(null)}
onSave={v => handleSave(setting.key, v)}
onClear={() => handleClear(setting.key)}
@@ -165,36 +223,21 @@ export default function DataSourcesPage() {
<p className="text-2xs text-ink-400"> API Key</p>
)}
{/* 行级操作提示 */}
{rowNotice && cardKeys.includes(rowNotice.key) && (
<Alert
kind={rowNotice.ok ? 'ok' : 'error'}
title={rowNotice.text}
/>
<Alert kind={rowNotice.ok ? 'ok' : 'error'} title={rowNotice.text} />
)}
{/* 最近采集 */}
<div className="flex items-center justify-between text-xs">
<span className="text-ink-400"></span>
<span className="text-ink-600">{formatTime(source.last_ingestion)}</span>
</div>
{/* 数据源健康:最近采集 + 异常提示 */}
{renderHealth(source.name)}
{/* 测试结果(进行中不渲染,避免占位被误读为失败) */}
{result && testingSource !== source.name && (
<Alert
kind={result.ok ? 'ok' : 'error'}
title={
result.ok
? `连接成功(${result.latency_ms}ms)`
: result.status
? `HTTP ${result.status}`
: '连接失败'
}
title={result.ok ? `连接成功(${result.latency_ms}ms)` : result.status ? `HTTP ${result.status}` : '连接失败'}
message={result.ok ? undefined : result.detail}
/>
)}
{/* 测试按钮 */}
<button
onClick={() => handleTest(source.name)}
disabled={testingSource === source.name}
@@ -209,23 +252,41 @@ export default function DataSourcesPage() {
</div>
)}
{/* 配置说明 */}
{/* 近期活动统计(只读) */}
{stats && stats.predictions && (
<Card>
<CardHeader title="近期预测活动" description="过去 24 小时 / 7 天的预测次数" />
<CardBody>
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">{stats.predictions.last_24h}</div>
<div className="mt-1 text-2xs text-ink-400"> 24 </div>
</div>
<div>
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">{stats.predictions.last_7d}</div>
<div className="mt-1 text-2xs text-ink-400"> 7 </div>
</div>
<div>
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">{stats.predictions.total}</div>
<div className="mt-1 text-2xs text-ink-400"></div>
</div>
</div>
</CardBody>
</Card>
)}
<Card>
<CardHeader title="配置说明" />
<CardBody>
<div className="space-y-3 text-xs leading-relaxed text-ink-600">
<p className="border-l-2 border-ink-300 pl-3">
<code className="font-mono">app_settings</code> <b></b>,
<code className="font-mono">.env</code> ; .env
,Key
<code className="font-mono">app_settings</code> <b></b>, <code className="font-mono">.env</code>; .env
</p>
<p className="border-l-2 border-ink-300 pl-3">
4 ( 8 ),
(); Key / /
</p>
<p className="border-l-2 border-ink-300 pl-3">
会真实请求上游接口一次:连通且密钥有效 ;
HTTP 401/403 ;
;,
</p>
</div>
</CardBody>
+21 -3
View File
@@ -155,7 +155,7 @@ export default function EvalPage() {
<Card>
<CardHeader
title="准确率对比"
description="按 provider × 模型聚合,仅统计有效预测"
description="按 provider × 模型 × prompt_version 聚合,仅统计有效预测"
/>
<CardBody>
{loading ? (
@@ -163,12 +163,15 @@ export default function EvalPage() {
<Spinner />
</div>
) : summary.length === 0 ? (
<EmptyText text="暂无评估数据,请调整筛选条件或先完成预测与结算" />
<EmptyState text="暂无评估数据,请调整筛选条件或先完成预测与结算" />
) : (
<DataTable
columns={[
{ key: 'provider', label: '提供商' },
{ key: 'model', label: '模型' },
{ key: 'prompt_version', label: '版本', render: (row: any) => (
<span className="font-mono text-2xs">{row.prompt_version ?? '—'}</span>
) },
{ key: 'total', label: '评估条数' },
{ key: 'accuracy_1x2', label: '1X2 准确率', render: (row: any) => (
<span className="tabular-nums">{row.accuracy_1x2 != null ? `${row.accuracy_1x2}%` : '—'}</span>
@@ -179,9 +182,24 @@ export default function EvalPage() {
{ key: 'avg_subjective_confidence', label: '平均置信度', render: (row: any) => (
<span className="tabular-nums">{row.avg_subjective_confidence != null ? row.avg_subjective_confidence.toFixed(2) : '—'}</span>
) },
{ key: 'calibration', label: '置信度校准(桶命中率)', render: (row: any) => (
row.calibration ? (
<div className="flex flex-wrap gap-x-3 gap-y-1 text-2xs">
{Object.entries(row.calibration).map(([name, b]: [string, any]) => (
<span key={name} className="inline-flex items-center gap-1">
<span className="text-ink-400">{name}:</span>
<span className="tabular-nums font-medium">
{b.hit_rate != null ? `${b.hit_rate}%` : '—'}
</span>
<span className="text-ink-300">({b.total})</span>
</span>
))}
</div>
) : '—'
) },
]}
data={summary}
rowKey={(row: any) => `${row.provider}-${row.model}`}
rowKey={(row: any) => `${row.provider}-${row.model}-${row.prompt_version ?? ''}`}
emptyText="暂无评估数据"
/>
)}
+112 -17
View File
@@ -101,16 +101,27 @@ export interface CollectionRequest {
// ── 评估 & 回测 ─────────────────────────────────────────────────
export interface EvalCalibrationBucket {
total: number
/** 该桶命中率,百分数;样本不足为 null */
hit_rate: number | null
}
export interface EvalSummaryRow {
provider: string
model: string
prompt_version: string | null
total: number
/** 1X2 准确率,百分数 0-100 */
accuracy_1x2?: number
avg_score_rmse?: number | null
avg_subjective_confidence?: number | null
/** 置信度校准:按主观置信度分桶的命中率 */
calibration?: Record<string, EvalCalibrationBucket>
}
export interface EvalSummary {
summary: Array<{
provider: string
model: string
total: number
/** 1X2 准确率,百分数 0-100 */
accuracy_1x2?: number
avg_score_rmse?: number | null
avg_subjective_confidence?: number | null
}>
summary: Array<EvalSummaryRow>
/** 全量已结算数 */
total_settled: number
/** 应用筛选后的已结算数 */
@@ -135,16 +146,11 @@ export interface BacktestRequest {
export interface BacktestSummary {
total: number
scored: number
success: number
degraded: number
accuracy_1x2?: number
avg_score_rmse?: number
results?: Array<{
match_id: number
actual_home: number
actual_away: number
pred_home?: number
pred_away?: number
correct_1x2: boolean
}>
avg_subjective_confidence?: number
}
// ── 数据源配置 ──────────────────────────────────────────────────
@@ -249,3 +255,92 @@ export interface LogEntry {
logger: string
message: string
}
// ── 数据源健康/最近采集状态 ─────────────────────────────────────
export interface IngestLastFailure {
at: string
logger: string
detail: string
note: string
}
export interface IngestSourceStatus {
name: string
label: string
key_configured: boolean
base_url?: string
reachable: boolean | null
status?: 'key_not_configured' | 'no_data' | 'has_data'
last_success_at: string | null
latest_match_date?: string | null
recent_count: number
note: string
last_failure: IngestLastFailure | null
}
// ── 比赛详情 ─────────────────────────────────────────────────────
export interface MatchRecentPrediction {
id: number
provider: string
model: string
mode: string
pred_home_goals: number | null
pred_away_goals: number | null
alt_pred_home_goals: number | null
alt_pred_away_goals: number | null
pred_1x2: string | null
subjective_confidence: number | null
reasoning: string | null
status: string
settled: boolean
correct_1x2?: boolean
created_at: string
actual_home_goals: number | null
actual_away_goals: number | null
agent_outputs?: Array<Record<string, any>> | null
agent_weights?: Record<string, number> | null
}
export interface MatchDetailOut {
id: number
league_code: string | null
season: string | null
home_team: string
away_team: string
home_team_zh: string | null
away_team_zh: string | null
match_date: string
match_status: string
home_goals: number | null
away_goals: number | null
match_stage: string | null
home_xg: number | null
away_xg: number | null
recent_predictions: MatchRecentPrediction[]
}
export interface TeamRecentMatch {
match_date: string | null
home_team: string | null
away_team: string | null
home_goals: number | null
away_goals: number | null
}
export interface MatchContextOut {
home_recent: TeamRecentMatch[]
away_recent: TeamRecentMatch[]
h2h: TeamRecentMatch[]
}
// ── 管理区统计 ─────────────────────────────────────────────────
export interface AdminStats {
predictions: {
total: number
last_24h: number
last_7d: number
}
}
+4 -1
View File
@@ -65,11 +65,14 @@
disabled:hover:border-ink-300 disabled:hover:bg-transparent disabled:hover:text-ink-700;
}
.btn-sm {
@apply px-2.5 py-1 text-xs;
@apply px-2.5 py-1 text-xs min-h-[36px];
}
.btn-solid {
@apply border-ink-900 bg-ink-900 text-paper-50 hover:border-press hover:bg-press;
}
.btn-danger {
@apply border-press bg-transparent text-press hover:bg-press hover:text-paper-50;
}
/* ── 表单控件:方正、无圆角 ── */
.field {
+487 -124
View File
@@ -1,5 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import TeamSideTag from '../components/TeamSideTag'
import { fetchMatchDetail, fetchMatchContext } from '../admin/dal'
import type { MatchDetailOut, MatchContextOut, MatchRecentPrediction, TeamRecentMatch } from '../admin/types'
interface Match {
id: number
@@ -31,10 +33,14 @@ interface Prediction {
pred_1x2: string | null
subjective_confidence: number | null
reasoning: string | null
status: string
agent_outputs: AgentReport[] | null
agent_weights: Record<string, number> | null
context: string
latency_ms: number | null
prompt_tokens: number | null
completion_tokens: number | null
rate_limit_remaining: number | null
}
interface AgentReport {
@@ -182,6 +188,7 @@ function OutcomeLine({
export default function Matches() {
const [league, setLeague] = useState('E0')
const [status, setStatus] = useState('scheduled')
const [date, setDate] = useState('') // 日期筛选(空=全部),"today"=今日
const [matches, setMatches] = useState<Match[]>([])
const [nextCursor, setNextCursor] = useState<string | null>(null)
const [loadingMore, setLoadingMore] = useState(false)
@@ -191,6 +198,10 @@ export default function Matches() {
const [predictionFor, setPredictionFor] = useState<Match | null>(null)
const [error, setError] = useState<string | null>(null)
const [mode, setMode] = useState<'single' | 'multi'>('multi')
const [expandedId, setExpandedId] = useState<number | null>(null)
const [detailMap, setDetailMap] = useState<Record<number, MatchDetailOut>>({})
const [contextMap, setContextMap] = useState<Record<number, MatchContextOut>>({})
const [detailLoading, setDetailLoading] = useState<number | null>(null)
// 请求竞态防护:切换联赛/状态很快时,先发的慢请求可能后返回,
// 把旧结果覆盖到新筛选上。用递增序号只认最后一次请求的响应。
@@ -211,11 +222,12 @@ export default function Matches() {
const load = useCallback(async () => {
const seq = ++loadSeq.current
setLoading(true)
// 切换筛选时作废进行中的「加载更多」,避免其标志位卡住
setLoadingMore(false)
setError(null)
try {
const params = new URLSearchParams({ league, status, limit: '50' })
if (date === 'today') params.set('date', todayStr())
else if (date) params.set('date', date)
const res = await fetch(`/api/v1/matches?${params}`)
if (seq !== loadSeq.current) return // 已有更新的请求,丢弃本次结果
if (!res.ok) throw new Error(`HTTP ${res.status}`)
@@ -229,7 +241,7 @@ export default function Matches() {
} finally {
if (seq === loadSeq.current) setLoading(false)
}
}, [league, status])
}, [league, status, date])
// 加载下一页(游标分页)
const loadMore = async () => {
@@ -238,6 +250,8 @@ export default function Matches() {
setLoadingMore(true)
try {
const params = new URLSearchParams({ league, status, limit: '50', cursor: nextCursor })
if (date === 'today') params.set('date', todayStr())
else if (date) params.set('date', date)
const res = await fetch(`/api/v1/matches?${params}`)
if (seq !== loadSeq.current) return
if (!res.ok) throw new Error(`HTTP ${res.status}`)
@@ -255,7 +269,26 @@ export default function Matches() {
useEffect(() => { load() }, [load])
/** 今日日期 YYYY-MM-DD(用于「今日」快速筛选) */
function todayStr(): string {
return new Date().toISOString().slice(0, 10)
}
/** 按日期分组(YYYY-MM-DD → Match[]),保持时间序 */
function groupByDate(list: Match[]): Array<[string, Match[]]> {
const map = new Map<string, Match[]>()
for (const m of list) {
const key = (m.match_date || '').slice(0, 10)
const arr = map.get(key)
if (arr) arr.push(m)
else map.set(key, [m])
}
return [...map.entries()]
}
const predict = async (m: Match) => {
// 防连点:若该场比赛已在预测中,直接忽略
if (predictingId === m.id) return
const seq = ++predictSeq.current
setPredictingId(m.id)
setError(null)
@@ -285,9 +318,7 @@ export default function Matches() {
setError(
e instanceof DOMException && e.name === 'AbortError'
? '预测超时(5 分钟),请稍后重试或改用单次模式'
: e instanceof Error
? e.message
: String(e),
: readablePredictError(e),
)
} finally {
clearTimeout(timer)
@@ -366,6 +397,27 @@ export default function Matches() {
/>
</span>
<span className="inline-flex items-center gap-2.5">
<span className="text-2xs text-ink-400"></span>
<span className="inline-flex items-center gap-1">
<button
onClick={() => setDate(date === 'today' ? '' : 'today')}
className={`btn btn-sm px-2 ${date === 'today' ? 'btn-solid' : ''}`}
title="只看今日"
></button>
<input
type="date"
value={date === 'today' ? todayStr() : date}
onChange={e => setDate(e.target.value)}
className="field px-1.5 py-1 text-xs"
aria-label="按日期筛选"
/>
{date && (
<button onClick={() => setDate('')} className="text-ink-400 hover:text-ink-900" aria-label="清除日期" title="清除">×</button>
)}
</span>
</span>
<span className="ml-auto inline-flex items-center gap-3">
<span className="tabular-nums"> {matches.length} </span>
<button onClick={load} disabled={loading} className="btn btn-sm">
@@ -401,7 +453,7 @@ export default function Matches() {
/>
)}
{/* ── 赛程栏:表格化,行间细线 ── */}
{/* ── 赛程栏:表格化,行间细线,按日期分组 ── */}
<section aria-label="赛程">
{loading && <SkeletonRows n={4} />}
@@ -412,79 +464,129 @@ export default function Matches() {
</div>
)}
{!loading && matches.map(m => {
const st = STATUS_META[m.match_status] ?? { label: m.match_status, cls: 'text-ink-400' }
const homeName = m.home_team_zh || m.home_team
const awayName = m.away_team_zh || m.away_team
const busy = predictingId === m.id
const active = predictionFor?.id === m.id
const finished = m.match_status === 'finished'
{!loading && groupByDate(matches).map(([dateKey, group]) => (
<div key={dateKey}>
{/* 日期分组头 */}
<div className="sticky top-0 z-10 border-y border-ink-200 bg-paper-100 px-2 py-1.5 text-2xs font-medium tracking-wide text-ink-500">
{formatDateHeader(dateKey)} <span className="ml-1 text-ink-300">· {group.length} </span>
</div>
{group.map(m => {
const st = STATUS_META[m.match_status] ?? { label: m.match_status, cls: 'text-ink-400' }
const homeName = m.home_team_zh || m.home_team
const awayName = m.away_team_zh || m.away_team
const busy = predictingId === m.id
const finished = m.match_status === 'finished'
const expanded = expandedId === m.id
const detail = detailMap[m.id]
const ctx = contextMap[m.id]
return (
<div
key={m.id}
className={`border-b border-ink-200 px-1 py-3 transition-colors hover:bg-paper-100 ${
active ? 'bg-press-wash/50' : ''
}`}
>
<div className="flex flex-col gap-2 sm:grid sm:grid-cols-[88px_minmax(0,1fr)_64px_minmax(0,1fr)_56px_auto] sm:items-center sm:gap-x-3 sm:gap-y-0">
{/* 日期 + 状态:移动端同行,桌面端日期单独归列 */}
<div className="flex items-center justify-between sm:contents">
<span className="text-2xs tabular-nums text-ink-400">{fmtDate(m.match_date)}</span>
<span className={`text-2xs sm:hidden ${st.cls}`}>{st.label}</span>
</div>
// 展开时懒加载详情(只读,不触发 LLM)
async function toggleExpand() {
if (expanded) { setExpandedId(null); return }
setExpandedId(m.id)
if (!detailMap[m.id] || !contextMap[m.id]) {
setDetailLoading(m.id)
try {
const [d, c] = await Promise.all([
fetchMatchDetail(m.id).catch(() => null),
fetchMatchContext(m.id).catch(() => null),
])
if (d) setDetailMap(prev => ({ ...prev, [m.id]: d }))
if (c) setContextMap(prev => ({ ...prev, [m.id]: c }))
} finally {
setDetailLoading(null)
}
}
}
{/* 对阵:移动端主队/比分/客队同一行,桌面端 sm:contents 拆回 grid 列 */}
<div className="flex items-center gap-2 sm:contents">
{/* 主队(右对齐) */}
<div className="flex min-w-0 flex-1 items-center justify-end gap-1.5">
<TeamSideTag side="home" />
<span className="truncate text-sm font-medium text-ink-900">{homeName}</span>
return (
<div key={m.id}>
{/* 行:可点击展开 */}
<div
className={`border-b border-ink-200 px-1 py-3 transition-colors hover:bg-paper-100 cursor-pointer ${
expanded ? 'bg-paper-100/60' : ''
}`}
onClick={toggleExpand}
role="button"
tabIndex={0}
onKeyDown={e => { if (e.key === 'Enter') toggleExpand() }}
aria-expanded={expanded}
>
{/* 小屏:日期+状态行;桌面:日期单独一列 */}
<div className="flex items-center justify-between sm:contents">
<span className="text-2xs tabular-nums text-ink-400">{fmtDate(m.match_date)}</span>
<span className={`text-2xs sm:hidden ${st.cls}`}>{st.label}</span>
</div>
{/* 比分 / VS */}
<div className="flex w-14 flex-shrink-0 flex-col items-center sm:w-auto">
{m.home_goals !== null && m.away_goals !== null ? (
<span className="font-serif text-base font-bold tabular-nums text-ink-900">
{m.home_goals}<span className="mx-0.5 font-normal text-ink-300">:</span>{m.away_goals}
</span>
) : (
<span className="text-2xs tracking-widest text-ink-400">VS</span>
)}
{m.home_xg !== null && m.away_xg !== null && (
<span className="text-2xs tabular-nums text-ink-400">
xG {m.home_xg.toFixed(1)}-{m.away_xg.toFixed(1)}
</span>
)}
{/* 对阵行:小屏主队(弹性)/比分/客队(弹性)三格;桌面 sm:contents 走 grid */}
<div className="flex items-center gap-2">
<span className="flex min-w-0 flex-1 items-center justify-end gap-1.5">
<TeamSideTag side="home" />
<span className="truncate text-sm font-medium text-ink-900">{homeName}</span>
</span>
<span className="flex w-16 flex-shrink-0 flex-col items-center">
{m.home_goals !== null && m.away_goals !== null ? (
<span className="font-serif text-base font-bold tabular-nums text-ink-900">
{m.home_goals}<span className="mx-0.5 font-normal text-ink-300">:</span>{m.away_goals}
</span>
) : (
<span className="text-2xs tracking-widest text-ink-400">VS</span>
)}
{m.home_xg !== null && m.away_xg != null && (
<span className="text-2xs tabular-nums text-ink-400">
xG {m.home_xg.toFixed(1)}-{m.away_xg.toFixed(1)}
</span>
)}
</span>
<span className="flex min-w-0 flex-1 items-center gap-1.5">
<TeamSideTag side="away" />
<span className="truncate text-sm font-medium text-ink-900">{awayName}</span>
</span>
</div>
{/* 客队(左对齐) */}
<div className="flex min-w-0 flex-1 items-center gap-1.5">
<TeamSideTag side="away" />
<span className="truncate text-sm font-medium text-ink-900">{awayName}</span>
</div>
</div>
{/* 状态列(桌面) */}
<span className={`hidden text-right text-2xs sm:block ${st.cls}`}>{st.label}</span>
{/* 预测按钮 */}
<div className="flex justify-end">
{/* 预测按钮:小屏独占一行(桌面端 sm:contents 下隐藏) */}
{!finished && (
<button
onClick={() => predict(m)}
disabled={busy}
className="btn btn-sm w-[76px]"
title={`${mode === 'multi' ? '多专家' : '单次'}模式预测这场`}
>
{busy ? (<><Spinner /> </>) : '预测'}
</button>
<div className="flex justify-end sm:hidden" onClick={e => e.stopPropagation()}>
<button
onClick={() => predict(m)}
disabled={busy}
className="btn min-h-[44px] px-4"
title={`${mode === 'multi' ? '多专家' : '单次'}模式预测这场`}
>
{busy ? (<><Spinner /> </>) : '预测'}
</button>
</div>
)}
{/* 桌面端按钮(小屏隐藏) */}
<span className={`hidden text-right text-2xs sm:block ${st.cls}`}>{st.label}</span>
<div className="hidden sm:flex sm:justify-end" onClick={e => e.stopPropagation()}>
{!finished && (
<button
onClick={() => predict(m)}
disabled={busy}
className="btn btn-sm w-[76px]"
title={`${mode === 'multi' ? '多专家' : '单次'}模式预测这场`}
>
{busy ? (<><Spinner /> </>) : '预测'}
</button>
)}
</div>
</div>{/* 关闭可点击行(clickable row) */}
{/* 展开详情面板(只读数据 + 预测按钮 + 历史预测 + 专家报告入口) */}
{expanded && (
<MatchDetailPanel
match={m} detail={detail} ctx={ctx}
loading={detailLoading === m.id}
mode={mode}
/>
)}
</div>
</div>
</div>
)
})}
)
})}
</div>
))}
{!loading && nextCursor && (
<div className="flex justify-center pt-4">
@@ -499,6 +601,55 @@ export default function Matches() {
)
}
/** 日期分组头显示:今日/明天/周几 · 年月日 */
function formatDateHeader(dateKey: string): string {
if (!dateKey) return '未开赛'
const d = new Date(dateKey + 'T00:00:00')
if (isNaN(d.getTime())) return dateKey
const today = new Date()
const todayKey = today.toISOString().slice(0, 10)
const tmr = new Date(today)
tmr.setDate(tmr.getDate() + 1)
const tmrKey = tmr.toISOString().slice(0, 10)
const weekday = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][d.getDay()]
if (dateKey === todayKey) return `今日 ${weekday}`
if (dateKey === tmrKey) return `明日 ${weekday}`
return `${d.getMonth() + 1}${d.getDate()}${weekday}`
}
/** 预测成本展示:耗时 + token + 限流余量 */
function PredictionCost({ prediction }: { prediction: Prediction }) {
const latency = prediction.latency_ms != null ? `${(prediction.latency_ms / 1000).toFixed(1)}s` : null
const tokens = prediction.prompt_tokens != null || prediction.completion_tokens != null
? `${prediction.prompt_tokens ?? '?'}/${prediction.completion_tokens ?? '?'}`
: null
if (!latency && !tokens && prediction.rate_limit_remaining == null) return null
return (
<div className="border-t border-ink-200 pt-3 text-2xs text-ink-500">
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1">
{latency && (
<span className="inline-flex items-center gap-1">
<span aria-hidden="true" className="opacity-60"></span> {latency}
</span>
)}
{tokens && (
<span className="inline-flex items-center gap-1">
<span aria-hidden="true" className="opacity-60">Tok</span>prompt/completion: {tokens}
</span>
)}
{prediction.rate_limit_remaining != null && prediction.rate_limit_remaining <= 3 && (
<span className="text-press" title="每分钟最多 10 次预测">
: {prediction.rate_limit_remaining}/10()
</span>
)}
</div>
</div>
)
}
/** 预测过程阶段(按时长模拟;结果到达即跳到完成) */
function PredictProgress({ mode }: { mode: 'single' | 'multi' }) {
@@ -573,8 +724,15 @@ function PredictProgress({ mode }: { mode: 'single' | 'multi' }) {
)}
<p className="mt-6 text-center text-2xs text-ink-400">
{mode === 'multi' ? '五路专家并行分析后终裁,约需 30-90 秒;关闭窗口即取消' : '单次调用,约需 5-20 秒;关闭窗口即取消'}
{mode === 'multi'
? '五路专家并行分析后终裁,约需 30-90 秒;多专家调用消耗较多 token,请按需使用。关闭窗口即取消'
: '单次调用,约需 5-20 秒;关闭窗口即取消'}
</p>
{mode === 'multi' && (
<p className="mt-1 text-center text-2xs text-ink-300">
提示:每分钟限 10 ,
</p>
)}
</div>
)
}
@@ -670,12 +828,14 @@ function PredictionPanel({
embedded?: boolean
}) {
const homeName = match.home_team_zh || match.home_team
const [expertsOpen, setExpertsOpen] = useState(false)
const awayName = match.away_team_zh || match.away_team
const okReports = (prediction.agent_outputs ?? []).filter(r => r.status === 'ok')
const degraded = prediction.status === 'degraded' || prediction.status === 'failed'
const reports = prediction.agent_outputs ?? []
const okReports = reports.filter(r => r.status === 'ok')
return (
<article className={embedded ? 'bg-paper-50' : 'border border-ink-900 bg-paper-50'}>
{/* 版头(嵌入模式由弹窗报头承担) */}
{!embedded && (
<div className="flex flex-wrap items-baseline justify-between gap-2 border-b border-ink-900 bg-paper-100 px-4 py-2.5 sm:px-5">
<h3 className="flex flex-wrap items-center gap-1.5 font-serif text-sm font-bold text-ink-900">
@@ -694,71 +854,113 @@ function PredictionPanel({
)}
<div className="space-y-7 px-4 py-6 sm:px-5">
{/* ── 预测比分:版面核心,大号宋体 ── */}
<div className="text-center">
<p className="font-serif text-5xl font-bold tabular-nums leading-none text-ink-900 sm:text-6xl">
{prediction.pred_home_goals ?? '-'}
<span className="mx-3 font-normal text-ink-300">:</span>
{prediction.pred_away_goals ?? '-'}
</p>
<p className="mt-3 text-2xs tracking-[0.5em] text-ink-400"></p>
{prediction.alt_pred_home_goals !== null && prediction.alt_pred_away_goals !== null && (
<p className="mt-2 text-2xs tabular-nums text-ink-400">
{' '}
<span className="font-serif text-sm font-bold tabular-nums text-ink-600">
{prediction.alt_pred_home_goals}<span className="mx-0.5 font-normal text-ink-300">:</span>{prediction.alt_pred_away_goals}
</span>
{/* ── degraded / failed 态:醒目警示 + 原因,不展示虚假比分 ── */}
{degraded && (
<div className="border-l-2 border-press bg-press-wash/40 px-4 py-3">
<p className="font-serif text-sm font-bold text-press-dark">
{prediction.status === 'failed' ? '预测失败' : '预测降级(degraded)'}
</p>
)}
</div>
<p className="mt-1.5 whitespace-pre-wrap text-xs leading-relaxed text-ink-600">
{prediction.reasoning || '所有专家均无有效数据或调用失败,无法生成可靠比分。'}
</p>
</div>
)}
{/* ── 胜平负 ── */}
<div className="border-y border-ink-200 py-4">
<OutcomeLine pick={prediction.pred_1x2} confidence={prediction.subjective_confidence} />
</div>
{/* ── 元信息一行 ── */}
<p className="text-center text-2xs text-ink-500">
{mode === 'multi' ? `多专家模式 · ${okReports.length}/${prediction.agent_outputs?.length ?? 0} 路有效` : '单次模式'}
{prediction.prompt_version && ` · prompt ${prediction.prompt_version}`}
{prediction.latency_ms !== null && ` · 终裁耗时 ${(prediction.latency_ms / 1000).toFixed(1)}`}
</p>
{/* ── 专家意见 ── */}
{prediction.agent_outputs && prediction.agent_outputs.length > 0 && (
<section>
<div className="section-head flex flex-wrap items-baseline justify-between gap-1">
<span></span>
{prediction.agent_weights && (
<span className="font-sans text-2xs font-normal text-ink-500">
:{Object.entries(prediction.agent_weights)
.sort((a, b) => b[1] - a[1])
.map(([k, v]) => `${AGENT_LABELS[k] ?? k} ${Math.round(v * 100)}%`)
.join(' / ')}
</span>
{/* ── 主结论(仅 success 展示) ── */}
{!degraded && (
<>
<div className="text-center">
<p className="font-serif text-5xl font-bold tabular-nums leading-none text-ink-900 sm:text-6xl">
{prediction.pred_home_goals ?? '-'}
<span className="mx-3 font-normal text-ink-300">:</span>
{prediction.pred_away_goals ?? '-'}
</p>
<p className="mt-3 text-2xs tracking-[0.5em] text-ink-400"></p>
{prediction.alt_pred_home_goals != null && prediction.alt_pred_away_goals != null && (
<p className="mt-2 text-2xs tabular-nums text-ink-400">
{' '}
<span className="font-serif text-sm font-bold tabular-nums text-ink-600">
{prediction.alt_pred_home_goals}<span className="mx-0.5 font-normal text-ink-300">:</span>{prediction.alt_pred_away_goals}
</span>
</p>
)}
</div>
<div>
{prediction.agent_outputs.map((r, i) => (
<AgentCard key={r.agent} report={r} no={CN_NUM[i] ?? String(i + 1)} />
))}
<div className="border-y border-ink-200 py-4">
<OutcomeLine pick={prediction.pred_1x2} confidence={prediction.subjective_confidence} />
</div>
</section>
</>
)}
{/* ── 终裁意见:引文式,红竖线 ── */}
{prediction.reasoning && (
{/* ── 成本信息(耗时 + token + 限流余量) ── */}
{!degraded && (
<PredictionCost prediction={prediction} />
)}
{/* ── 元信息 ── */}
<p className="text-center text-2xs text-ink-500">
{mode === 'multi' ? `多专家模式 · ${okReports.length}/${reports.length} 路有效` : '单次模式'}
{prediction.prompt_version && ` · prompt ${prediction.prompt_version}`}
</p>
{/* ── 终裁/降级说明意见 ── */}
{prediction.reasoning && degraded && (
<section>
<h4 className="section-head mb-3"></h4>
<h4 className="section-head mb-2"></h4>
<blockquote className="border-l-2 border-press pl-4">
<p className="whitespace-pre-wrap font-serif text-sm leading-loose text-ink-700">
{prediction.reasoning}
</p>
<p className="whitespace-pre-wrap font-serif text-sm leading-loose text-ink-700">{prediction.reasoning}</p>
</blockquote>
</section>
)}
{/* ── 专家意见(多模式):可折叠 + 状态摘要 + 权重条形图 ── */}
{mode === 'multi' && reports.length > 0 && (
<section>
<button
onClick={() => setExpertsOpen(o => !o)}
className="flex w-full items-center justify-between border-b border-ink-200 pb-2 text-left"
>
<span className="section-head mb-0">({okReports.length}/{reports.length} )</span>
<span className="text-2xs text-ink-400">{expertsOpen ? '收起' : '展开'}</span>
</button>
{/* 权重条形图(仅 success 且有权重时显示) */}
{!degraded && prediction.agent_weights && Object.keys(prediction.agent_weights).length > 0 && (
<div className="mt-3 space-y-1.5">
<span className="text-2xs text-ink-500"></span>
{Object.entries(prediction.agent_weights)
.sort((a, b) => b[1] - a[1])
.map(([k, v]) => (
<div key={k} className="grid grid-cols-[96px_minmax(0,1fr)_40px] items-center gap-2">
<span className="truncate text-2xs text-ink-500">{AGENT_LABELS[k] ?? k}</span>
<div className="h-1.5 bg-paper-100">
<div className="h-full bg-press" style={{ width: `${Math.round(v * 100)}%` }} />
</div>
<span className="text-right text-2xs tabular-nums text-ink-500">{Math.round(v * 100)}%</span>
</div>
))}
</div>
)}
{expertsOpen && (
<div className="mt-2">
{reports.map((r, i) => (
<AgentCard key={r.agent} report={r} no={CN_NUM[i] ?? String(i + 1)} />
))}
</div>
)}
</section>
)}
{/* ── 终裁意见(success) ── */}
{prediction.reasoning && !degraded && mode === 'multi' && (
<section>
<h4 className="section-head mb-3"></h4>
<blockquote className="border-l-2 border-press pl-4">
<p className="whitespace-pre-wrap font-serif text-sm leading-loose text-ink-700">{prediction.reasoning}</p>
</blockquote>
</section>
)}
</div>
</article>
)
@@ -862,3 +1064,164 @@ function AgentCard({ report: r, no }: { report: AgentReport; no: string }) {
</details>
)
}
/** 比赛详情面板:双方近况/H2H + 历史预测列表(只读) */
function MatchDetailPanel({
match, detail, ctx, loading, mode,
}: {
match: Match
detail: MatchDetailOut | undefined
ctx: MatchContextOut | undefined
loading: boolean
mode: 'single' | 'multi'
}) {
const homeName = match.home_team_zh || match.home_team
const awayName = match.away_team_zh || match.away_team
const finished = match.match_status === 'finished'
return (
<div className="border-b border-ink-200 bg-paper-100/50 px-3 py-4">
{loading && (
<div className="flex items-center gap-2 text-xs text-ink-500"><Spinner /> </div>
)}
{!loading && !detail && !ctx && (
<p className="py-4 text-center text-xs text-ink-400"></p>
)}
{!loading && (detail || ctx) && (
<div className="space-y-5">
{/* 比分区(终场/当前比分 + 状态 + 预测按钮) */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="text-center">
<p className="font-serif text-3xl font-bold tabular-nums leading-none text-ink-900">
{match.home_goals ?? '-'}{' '}<span className="text-ink-300">:</span>{' '}{match.away_goals ?? '-'}
</p>
<p className="mt-1 text-2xs text-ink-500">
{match.match_stage || ''} {match.match_status === 'finished' ? '· 已完赛' : match.match_status === 'scheduled' ? '· 未开赛' : `· ${match.match_status}`}
</p>
{match.home_xg != null && match.away_xg != null && (
<p className="text-2xs tabular-nums text-ink-400">xG {match.home_xg.toFixed(1)}{match.away_xg.toFixed(1)}</p>
)}
</div>
{!finished && (
<span className="text-2xs text-ink-500">
{mode === 'multi' ? '多专家' : '单次'}
</span>
)}
</div>
{/* 双方近况 + H2H */}
{(ctx?.home_recent?.length || ctx?.away_recent?.length || ctx?.h2h?.length) ? (
<div className="grid gap-4 sm:grid-cols-3">
<RecentBlock title={`${homeName} 近况`} rows={ctx?.home_recent} side="home" />
<RecentBlock title={`${awayName} 近况`} rows={ctx?.away_recent} side="away" />
<RecentBlock title="历史交锋(H2H)" rows={ctx?.h2h} side="h2h" />
</div>
) : (
!loading && <p className="text-2xs text-ink-400"></p>
)}
{/* 历史预测列表 */}
<div>
<h4 className="section-head mb-2">({detail?.recent_predictions?.length ?? 0})</h4>
{detail?.recent_predictions?.length ? (
<div className="space-y-2">
{detail.recent_predictions.map(p => (
<PredictionHistoryRow key={p.id} p={p} mode={mode} />
))}
</div>
) : (
<p className="py-3 text-center text-2xs text-ink-400"></p>
)}
</div>
</div>
)}
</div>
)
}
/** 近况/H2H 单区块 */
function RecentBlock({ title, rows, side }: { title: string; rows?: TeamRecentMatch[]; side: 'home' | 'away' | 'h2h' }) {
return (
<div>
<h5 className="mb-1.5 text-2xs font-medium text-ink-500">{title}</h5>
{rows && rows.length > 0 ? (
<ul className="space-y-1">
{rows.map((r, i) => {
const date = r.match_date ? r.match_date.slice(5, 10) : '—'
const score = (r.home_goals != null && r.away_goals != null) ? `${r.home_goals}-${r.away_goals}` : 'vs'
const label = side === 'h2h'
? `${r.home_team ?? '?'} ${score} ${r.away_team ?? '?'}`
: `${score}`
return (
<li key={i} className="flex items-center justify-between text-2xs tabular-nums text-ink-600">
<span className="text-ink-400">{date}</span>
<span className="truncate">{label}</span>
</li>
)
})}
</ul>
) : (
<p className="text-2xs text-ink-300"></p>
)}
</div>
)
}
/** 历史预测单行(含专家报告入口) */
function PredictionHistoryRow({ p, mode }: { p: MatchRecentPrediction; mode: 'single' | 'multi' }) {
const badge = p.status === 'degraded'
? { label: 'degraded', cls: 'text-press' }
: p.settled
? { label: p.correct_1x2 === undefined ? '已结算' : p.correct_1x2 ? '命中' : '未中', cls: p.correct_1x2 ? 'text-ink-900' : 'text-ink-400' }
: { label: p.status === 'success' ? '成功' : p.status, cls: 'text-ink-600' }
const score = (p.pred_home_goals != null && p.pred_away_goals != null)
? `${p.pred_home_goals.toFixed(1)}-${p.pred_away_goals.toFixed(1)}`
: '—'
const alt = (p.alt_pred_home_goals != null && p.alt_pred_away_goals != null)
? `${p.alt_pred_home_goals.toFixed(1)}-${p.alt_pred_away_goals.toFixed(1)}` : null
const hasAgents = mode === 'multi' && p.agent_outputs && p.agent_outputs.length > 0
return (
<div className="border-b border-ink-200 pb-2 last:border-b-0">
<div className="flex items-center justify-between text-xs">
<span className="tabular-nums text-ink-600">
{score} {p.pred_1x2 ? `(${p.pred_1x2})` : ''}
{alt && <span className="ml-1 text-ink-400"> {alt}</span>}
</span>
<span className="flex items-center gap-2">
{p.subjective_confidence != null && (
<span className="text-2xs tabular-nums text-ink-400"> {Math.round(p.subjective_confidence * 100)}%</span>
)}
<span className={`text-2xs ${badge.cls}`}>{badge.label}</span>
</span>
</div>
<div className="mt-0.5 flex items-center justify-between text-2xs text-ink-400">
<span className="truncate">{p.model} · {p.mode} · {p.created_at?.slice(0, 16).replace('T', ' ') ?? '—'}</span>
{hasAgents && <span className="text-press">{p.agent_outputs!.length} </span>}
</div>
{p.reasoning && (
<p className="mt-1 line-clamp-2 font-serif text-2xs leading-relaxed text-ink-500">{p.reasoning}</p>
)}
</div>
)
}
function readablePredictError(e: unknown): string {
if (e instanceof Error) {
const m = e.message
if (/429/.test(m)) {
// 429 来自后端限流(每分钟 10 次),非上游 LLM
return '操作过于频繁:每分钟最多 10 次预测。为保护 LLM 额度,请稍后再试。'
}
if (/502/.test(m)) return 'LLM 服务暂时不可用(502),请稍后重试'
if (/402|Payment Required|额度|余额/.test(m)) return 'LLM 额度不足(402),请检查 API Key 余额'
if (/400|已完赛/.test(m)) return '该比赛已完赛,不再支持预测'
if (/409|已结算/.test(m)) return '该预测已结算,不能重新预测'
if (/timeout|超时|timed out/i.test(m)) return '请求超时,请稍后重试或改用单次模式'
return m
}
return String(e)
}
+2 -3
View File
@@ -17,12 +17,11 @@ dependencies = [
]
[project.optional-dependencies]
# 开发/CI 依赖:本地安装用 pip install -e ".[dev]",CI 用 pip-sync requirements-dev.txt
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.23",
"httpx>=0.27",
"alembic>=1.13",
"cryptography>=42.0",
"pip-tools>=7.0",
]
[build-system]
+1211
View File
File diff suppressed because it is too large Load Diff
+1184
View File
File diff suppressed because it is too large Load Diff
+16 -3
View File
@@ -10,6 +10,8 @@ from fastapi.middleware.cors import CORSMiddleware
from src.core.config import settings
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
@@ -19,9 +21,11 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
ensure_admin_password_hashed,
migrate_plaintext_sensitive_settings,
)
from src.core.security_check import assert_security_on_startup
await init_db() # 验证连接,不建表
await migrate_plaintext_sensitive_settings() # 明文敏感配置 → 加密(幂等)
await ensure_admin_password_hashed() # .env 明文密码 → scrypt 哈希(幂等)
await assert_security_on_startup() # 启动安全校验(生产拒绝/开发警告)
yield
await close_client()
@@ -74,14 +78,23 @@ def create_app() -> FastAPI:
@app.get("/health/ready")
async def health_ready():
"""就绪检查: 验证数据库连接。"""
"""就绪检查:验证数据库连接。
数据库不可达时返回 HTTP 503,而非 200 + not_ready ——
这样 K8s/Compose 的 readinessProbe 才能正确判定「未就绪」并停止流量。
"""
from src.db.base import engine
from fastapi.responses import JSONResponse
try:
async with engine.begin() as conn:
await conn.run_sync(lambda conn: None)
return {"status": "ready"}
except Exception:
return {"status": "not_ready"}
except Exception as e:
logger.warning("就绪检查失败(数据库不可达): %s", e)
return JSONResponse(
status_code=503,
content={"status": "not_ready", "reason": "database_unreachable"},
)
return app
+6
View File
@@ -167,6 +167,12 @@ class _RateLimiter:
self._hits[key] = timestamps
return True
def remaining(self, key: str) -> int:
"""当前窗口内剩余可用次数。"""
now = time.time()
timestamps = [t for t in self._hits.get(key, []) if t > now - self.window_seconds]
return max(0, self.max_requests - len(timestamps))
# 全局限流实例: /api/v1/predict 每分钟 10 次
_predict_limiter = _RateLimiter(max_requests=10, window_seconds=60)
+122 -2
View File
@@ -7,7 +7,7 @@ from __future__ import annotations
import logging
import time
from datetime import date, datetime, timezone
from datetime import date, datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
@@ -28,7 +28,7 @@ from src.core.runtime_config import (
set_runtime_value,
)
from src.db.base import AsyncSession, get_db_read
from src.db.models import Injury, MatchStats
from src.db.models import Injury, Match, MatchStats
logger = logging.getLogger(__name__)
@@ -317,3 +317,123 @@ async def test_datasource(name: str):
"https://v3.football.api-sports.io/status",
headers={"x-apisports-key": api_key},
)
# ── 数据源健康/最近采集状态(只读,不触发采集) ──────────────────────
@router.get("/ingest/status")
async def ingest_status(db: AsyncSession = Depends(get_db_read)):
"""各数据源采集健康概览(只读,不触发任何采集)。
返回尽量可得的信息;基于现有表近似的数据会标明 approximation。
失败追踪目前依赖系统日志缓冲,无专用采集失败表。
"""
# ── bzzoiro: 落库目标是 matches 表,无专用采集时间戳 ──
# 近似:以 matches 表最大 match_date(已覆盖的最远比赛日) 与 created_at 作为参考
bzzoiro_key = await get_runtime_value("BZZOIRO_KEY")
bzzoiro_base = await get_runtime_value("BZZOIRO_BASE")
row = (
await db.execute(
select(
func.count().label("cnt"),
func.max(Match.match_date).label("latest_match_date"),
func.max(Match.created_at).label("latest_row_at"),
).where(Match.match_status == "finished")
)
).one()
bzzoiro = {
"name": "bzzoiro",
"label": "Bzzoiro",
"key_configured": bool(bzzoiro_key),
"base_url": (bzzoiro_base.rstrip("/") if bzzoiro_base else None) or settings.BZZOIRO_BASE,
"reachable": None, # 不主动探测
"last_success_at": row.latest_row_at.isoformat() if row.latest_row_at else None,
"latest_match_date": row.latest_match_date.isoformat() if row.latest_match_date else None,
"recent_count": row.cnt or 0,
"note": "approx:基于 matches.finished 表,last_success_at 为行写入时间而非精确采集完成时间",
"last_failure": _last_failure_log("bzzoiro"),
}
# ── understat: 落库到 match_stats(source=understat),有精确 retrieved_at ──
row = (
await db.execute(
select(
func.count().label("cnt"),
func.max(MatchStats.retrieved_at).label("latest_retrieved"),
).where(MatchStats.source == "understat")
)
).one()
understat = {
"name": "understat",
"label": "Understat",
"key_configured": True, # 无需 Key
"reachable": None,
"last_success_at": row.latest_retrieved.isoformat() if row.latest_retrieved else None,
"recent_count": row.cnt or 0,
"note": "基于 match_stats.source=understat 的 retrieved_at",
"last_failure": _last_failure_log("understat"),
}
# ── injuries: 落库到 injuries 表,有精确 retrieved_at;区分 Key/无数据/有数据 ──
api_key = await get_runtime_value("API_FOOTBALL_KEY")
row = (
await db.execute(
select(
func.count().label("cnt"),
func.max(Injury.retrieved_at).label("latest_retrieved"),
)
)
).one()
if not api_key:
injuries_status, injuries_note = "key_not_configured", "API_FOOTBALL_KEY 未配置"
elif not row.cnt:
injuries_status, injuries_note = "no_data", "本地无伤停数据,请先采集"
else:
injuries_status, injuries_note = "has_data", f"{row.cnt} 条伤停记录"
injuries = {
"name": "injuries",
"label": "Injuries (API-Football)",
"key_configured": bool(api_key),
"reachable": None,
"status": injuries_status,
"last_success_at": row.latest_retrieved.isoformat() if row.latest_retrieved else None,
"recent_count": row.cnt or 0,
"note": injuries_note,
"last_failure": _last_failure_log("injuries"),
}
return {"sources": [bzzoiro, understat, injuries]}
def _last_failure_log(source: str) -> dict | None:
"""从系统日志缓冲中查找某数据源的最近一次错误(仅作参考,非专用失败表)。"""
entries = get_entries(min_level="ERROR", keyword=source, limit=5)
if not entries:
return None
e = entries[0]
return {
"at": datetime.fromtimestamp(e["ts"]).isoformat(),
"logger": e["logger"],
"detail": e["message"][:200],
"note": "approx:来自内存日志缓冲,非专用采集失败表;进程重启后清零",
}
@router.get("/stats")
async def admin_stats(db: AsyncSession = Depends(get_db_read)):
"""管理区统计(只读):最近预测次数。轻量聚合,无 LLM 调用。"""
from sqlalchemy import func, text
from src.db.models import Prediction
day_ago = datetime.now(timezone.utc) - timedelta(days=1)
week_ago = datetime.now(timezone.utc) - timedelta(days=7)
r = (
await db.execute(
select(
func.count().label("total"),
func.count().filter(Prediction.created_at >= day_ago).label("last_24h"),
func.count().filter(Prediction.created_at >= week_ago).label("last_7d"),
)
)
).one()
return {"predictions": {"total": r.total, "last_24h": r.last_24h, "last_7d": r.last_7d}}
+9 -5
View File
@@ -28,13 +28,16 @@ async def backtest(req: BacktestRequest):
"""对历史比赛运行回测。
该接口会对每场已完赛比赛各发起一次 LLM 预测,成本高 —— 因此需要
`X-API-Key` 鉴权(见审查报告 P2-7)。
管理员鉴权(require_admin)。
对每场已完赛比赛:
1. 用比赛之前的数据构建上下文 (防未来信息泄漏)
2. 调 LLM 预测
3. 用实际比分回填
1. 用比赛之前的数据构建上下文 (防未来信息泄漏,cutoff=match_date-1天)
2. 调 LLM 预测(强制 use_cache=False,避免缓存命中导致反复 settle 同一行)
3. 用实际比分回填(settle)
4. 统计准确率 / RMSE / 校准度
限流:单请求上限 200 场(默认 20),避免一次打爆 LLM 额度。
回测写入 run_type='backtest',与实盘(live)互不覆盖(唯一键含 run_type)。
"""
try:
summary = await run_backtest(
@@ -53,10 +56,11 @@ async def backtest(req: BacktestRequest):
"summary": {
"total": summary.total,
"scored": summary.scored,
"success": summary.success,
"degraded": summary.degraded,
"accuracy_1x2": summary.accuracy_1x2,
"avg_score_rmse": summary.avg_score_rmse,
"avg_subjective_confidence": summary.avg_subjective_confidence,
"calibration": summary.calibration,
},
"results": [
{
+8 -2
View File
@@ -19,13 +19,19 @@ router = APIRouter(prefix="/api/v1", tags=["eval"])
async def settle(req: SettleRequest, db: AsyncSession = Depends(get_db)):
"""回填实际结果。
status 为 degraded/failed 的预测无法结算
status 为 degraded/failed 的预测无法结算(返回 400);
记录不存在返回 404。
"""
try:
pred = await settle_prediction(req.prediction_id, req.home_goals, req.away_goals)
return {"id": pred.id, "settled": pred.settled}
except ValueError as e:
logger.warning("settle failed: %s", e)
msg = str(e)
# degraded/failed 拒绝:明确的 400,而非与"未找到"混为一谈
if "无法结算" in msg:
logger.warning("settle rejected: %s", msg)
raise HTTPException(400, msg)
logger.warning("settle failed: %s", msg)
raise HTTPException(404, "预测记录不存在")
except Exception as e:
logger.exception("settle error")
+1
View File
@@ -73,6 +73,7 @@ async def _run_bzzoiro(leagues: list[str], date_from: str | None, date_to: str |
logger.warning("bzzoiro 部分联赛存在错误: %s", sample)
if merged["errors"]:
logger.warning("bzzoiro 采集错误 %d 条: %s", len(merged["errors"]), merged["errors"][:3])
logger.debug("bzzoiro 采集明细: leagues=%s", list(merged["leagues"].keys()))
except Exception:
logger.exception("bzzoiro 采集任务失败")
+108 -4
View File
@@ -4,13 +4,13 @@ from __future__ import annotations
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy import or_, select
from sqlalchemy.orm import selectinload
from src.api.deps import require_admin
from src.api.schemas import MatchListOut, MatchOut
from src.api.schemas import MatchListOut, MatchOut, PredictionOut
from src.db.base import AsyncSession, get_db_read
from src.db.models import League, Match
from src.db.models import League, Match, Prediction
router = APIRouter(prefix="/api/v1", tags=["data"])
@@ -114,12 +114,26 @@ async def list_matches(
async def get_match(match_id: int, db: AsyncSession = Depends(get_db_read)):
stmt = (
select(Match)
.options(selectinload(Match.league), selectinload(Match.home_team), selectinload(Match.away_team))
.options(
selectinload(Match.league),
selectinload(Match.home_team),
selectinload(Match.away_team),
selectinload(Match.stats),
)
.where(Match.id == match_id)
)
m = (await db.execute(stmt)).scalar_one_or_none()
if m is None:
raise HTTPException(404, "match not found")
# 最近预测(倒序,最多 5 条)——复用 PredictionOut 结构,只读,不触发 LLM
preds = (
await db.execute(
select(Prediction)
.where(Prediction.match_id == match_id)
.order_by(Prediction.created_at.desc())
.limit(5)
)
).scalars().all()
return MatchOut(
id=m.id,
league_code=m.league.code if m.league else None,
@@ -135,4 +149,94 @@ async def get_match(match_id: int, db: AsyncSession = Depends(get_db_read)):
match_stage=m.match_stage,
home_xg=m.stats.home_xg if m.stats else None,
away_xg=m.stats.away_xg if m.stats else None,
recent_predictions=[
PredictionOut(
id=p.id, match_id=p.match_id, provider=p.provider, model=p.model,
prompt_version=p.prompt_version, mode=p.mode or "single",
pred_home_goals=p.pred_home_goals, pred_away_goals=p.pred_away_goals,
alt_pred_home_goals=p.alt_pred_home_goals, alt_pred_away_goals=p.alt_pred_away_goals,
pred_1x2=p.pred_1x2, subjective_confidence=p.subjective_confidence,
reasoning=p.reasoning, status=p.status or "success",
agent_outputs=p.agent_outputs, agent_weights=p.agent_weights,
created_at=p.created_at, actual_home_goals=p.actual_home_goals,
actual_away_goals=p.actual_away_goals, settled=p.settled,
)
for p in preds
],
)
@router.get("/matches/{match_id}/context", dependencies=[Depends(require_admin)])
async def match_context(match_id: int, db: AsyncSession = Depends(get_db_read)):
"""比赛上下文(只读,不触发 LLM):双方近况 + 历史交锋。
全部基于现有数据聚合:
- recent_home / recent客队:该队最近 5 场已完赛(进球/结果)
- h2h:双方最近 5 次交手
若数据不足,对应列表为空(前端展示空态)。
"""
m = (
await db.execute(
select(Match)
.options(selectinload(Match.home_team), selectinload(Match.away_team))
.where(Match.id == match_id)
)
).scalar_one_or_none()
if m is None:
raise HTTPException(404, "match not found")
home_id = m.home_team_id
away_id = m.away_team_id
def _row_to_dict(row):
return {
"match_date": row.match_date.isoformat() if row.match_date else None,
"home_team": row.home_team.name_zh or row.home_team.name if row.home_team else None,
"away_team": row.away_team.name_zh or row.away_team.name if row.away_team else None,
"home_goals": row.home_goals,
"away_goals": row.away_goals,
}
# 主队近况(已完赛,含主/客场)
home_recent = (
await db.execute(
select(Match)
.where(Match.match_status == "finished", Match.home_team_id == home_id)
.order_by(Match.match_date.desc())
.limit(5)
.options(selectinload(Match.home_team), selectinload(Match.away_team))
)
).scalars().all()
# 客队近况
away_recent = (
await db.execute(
select(Match)
.where(Match.match_status == "finished", Match.away_team_id == away_id)
.order_by(Match.match_date.desc())
.limit(5)
.options(selectinload(Match.home_team), selectinload(Match.away_team))
)
).scalars().all()
# 历史交锋(双方已完赛)
h2h = (
await db.execute(
select(Match)
.where(
Match.match_status == "finished",
or_(
(Match.home_team_id == home_id) & (Match.away_team_id == away_id),
(Match.home_team_id == away_id) & (Match.away_team_id == home_id),
),
)
.order_by(Match.match_date.desc())
.limit(5)
.options(selectinload(Match.home_team), selectinload(Match.away_team))
)
).scalars().all()
return {
"home_recent": [_row_to_dict(r) for r in home_recent],
"away_recent": [_row_to_dict(r) for r in away_recent],
"h2h": [_row_to_dict(r) for r in h2h],
}
+71 -18
View File
@@ -42,7 +42,7 @@ async def predict(req: PredictRequest):
if m.match_status == "finished":
raise HTTPException(400, "该比赛已完赛,不再支持预测")
# 2. LLM 调用(不持有任何 DB 连接)
# 2. 预测调用(不持有任何 DB 连接)
try:
result = await predict_match(
req.match_id,
@@ -63,32 +63,77 @@ async def predict(req: PredictRequest):
logger.exception("predict unexpected error")
raise HTTPException(500, "预测失败,请查看服务器日志")
# baseline 模式:结果已是 dict,需独立落库(prediction_id)
if req.mode == "baseline":
prediction_id = await _persist_baseline(req.match_id, result)
else:
prediction_id = result.prediction_id
# 3. 结果映射(无 DB 访问)
logger.info(
"预测完成 match=%s mode=%s pred=%s:%s (%s)",
req.match_id, req.mode, result.pred_home_goals, result.pred_away_goals, result.pred_1x2,
req.match_id, req.mode,
result.get("pred_home_goals") if isinstance(result, dict) else result.pred_home_goals,
result.get("pred_away_goals") if isinstance(result, dict) else result.pred_away_goals,
result.get("pred_1x2") if isinstance(result, dict) else result.pred_1x2,
)
result_dict = result if isinstance(result, dict) else None
return PredictOut(
prediction_id=result.prediction_id,
provider=result.provider,
model=result.model,
prompt_version=getattr(result, "prompt_version", None),
mode=getattr(result, "mode", "single"),
pred_home_goals=result.pred_home_goals,
pred_away_goals=result.pred_away_goals,
alt_pred_home_goals=result.alt_pred_home_goals,
alt_pred_away_goals=result.alt_pred_away_goals,
pred_1x2=result.pred_1x2,
subjective_confidence=result.subjective_confidence,
reasoning=result.reasoning,
agent_outputs=getattr(result, "agent_outputs", None),
agent_weights=getattr(result, "agent_weights", None),
context=result.context,
latency_ms=result.latency_ms,
prediction_id=prediction_id,
provider=result.get("provider") if result_dict else result.provider,
model=result.get("model") if result_dict else result.model,
prompt_version=result.get("prompt_version") if result_dict else getattr(result, "prompt_version", None),
mode=req.mode,
pred_home_goals=result.get("pred_home_goals") if result_dict else result.pred_home_goals,
pred_away_goals=result.get("pred_away_goals") if result_dict else result.pred_away_goals,
alt_pred_home_goals=result.get("alt_pred_home_goals") if result_dict else result.alt_pred_home_goals,
alt_pred_away_goals=result.get("alt_pred_away_goals") if result_dict else result.alt_pred_away_goals,
pred_1x2=result.get("pred_1x2") if result_dict else result.pred_1x2,
subjective_confidence=result.get("subjective_confidence") if result_dict else result.subjective_confidence,
reasoning=result.get("reasoning") if result_dict else result.reasoning,
status=result.get("status", "success") if result_dict else getattr(result, "status", "success"),
agent_outputs=result.get("agent_outputs") if result_dict else getattr(result, "agent_outputs", None),
agent_weights=result.get("agent_weights") if result_dict else getattr(result, "agent_weights", None),
context=result.get("context", "") if result_dict else result.context,
latency_ms=result.get("latency_ms", 0) if result_dict else result.latency_ms,
prompt_tokens=result.get("prompt_tokens") if result_dict else getattr(result, "prompt_tokens", None),
completion_tokens=result.get("completion_tokens") if result_dict else getattr(result, "completion_tokens", None),
rate_limit_remaining=_predict_limiter.remaining(get_client_ip(request)),
)
async def _persist_baseline(match_id: int, baseline: dict) -> int:
"""将基线预测结果写入 prediction 表,复用 upsert 语义。"""
from src.db.unit_of_work import get_uow
from src.llm.predict import _upsert_prediction
async with get_uow() as session:
pred = await _upsert_prediction(
session,
match_id=match_id,
provider_name="baseline",
model="baseline",
mode="baseline",
run_type="baseline",
values={
"prompt_version": "baseline_v1",
"prompt_tokens": 0,
"completion_tokens": 0,
"latency_ms": 0,
"pred_home_goals": baseline["pred_home_goals"],
"pred_away_goals": baseline["pred_away_goals"],
"pred_1x2": baseline["pred_1x2"],
"subjective_confidence": baseline["subjective_confidence"],
"reasoning": baseline["reasoning"],
"raw_response": baseline.get("raw", baseline),
"status": "success",
},
)
return pred.id
@router.get("/predictions", response_model=list[PredictionOut], dependencies=[Depends(require_admin)])
async def list_predictions(
match_id: int | None = None,
@@ -110,10 +155,14 @@ async def list_predictions(
mode=p.mode or "single",
pred_home_goals=p.pred_home_goals,
pred_away_goals=p.pred_away_goals,
alt_pred_home_goals=p.alt_pred_home_goals,
alt_pred_away_goals=p.alt_pred_away_goals,
pred_1x2=p.pred_1x2,
subjective_confidence=p.subjective_confidence,
reasoning=p.reasoning,
status=p.status or "success",
agent_outputs=p.agent_outputs,
agent_weights=p.agent_weights,
created_at=p.created_at,
actual_home_goals=p.actual_home_goals,
actual_away_goals=p.actual_away_goals,
@@ -137,10 +186,14 @@ async def get_prediction(prediction_id: int, db: AsyncSession = Depends(get_db_r
mode=p.mode or "single",
pred_home_goals=p.pred_home_goals,
pred_away_goals=p.pred_away_goals,
alt_pred_home_goals=p.alt_pred_home_goals,
alt_pred_away_goals=p.alt_pred_away_goals,
pred_1x2=p.pred_1x2,
subjective_confidence=p.subjective_confidence,
reasoning=p.reasoning,
status=p.status or "success",
agent_outputs=p.agent_outputs,
agent_weights=p.agent_weights,
created_at=p.created_at,
actual_home_goals=p.actual_home_goals,
actual_away_goals=p.actual_away_goals,
+18 -1
View File
@@ -29,6 +29,8 @@ class MatchOut(BaseModel):
match_stage: str | None
home_xg: float | None = None
away_xg: float | None = None
# 该场比赛的最近预测摘要(按时间倒序,最多 5 条;无预测为空)
recent_predictions: list[PredictionOut] = []
class MatchListOut(BaseModel):
@@ -42,7 +44,13 @@ class PredictRequest(BaseModel):
provider: str | None = None
model: str | None = None
prompt_version: str | None = None
mode: str = "multi" # multi(默认, 5专家+终裁) | single(单次调用)
mode: str = Field(
"multi",
description="multi(默认,5专家+终裁) | single(单次) | baseline(极简统计基线,不调用 LLM)",
)
use_cache: bool = True
backtest: bool = False
cutoff_at: str | None = Field(None, description="显式截止时间 ISO8601,用于回测防未来信息")
class PredictOut(BaseModel):
@@ -58,6 +66,13 @@ class PredictOut(BaseModel):
pred_1x2: str | None
subjective_confidence: float | None
reasoning: str | None
status: str = "success"
# 成本信息(可选;单次/多专家均有)
latency_ms: int | None = None
prompt_tokens: int | None = None
completion_tokens: int | None = None
# 限流提示:当请求被节流时告知用户剩余配额(可选)
rate_limit_remaining: int | None = None
agent_outputs: list[dict] | None = None
agent_weights: dict | None = None
context: str
@@ -78,7 +93,9 @@ class PredictionOut(BaseModel):
pred_1x2: str | None
subjective_confidence: float | None
reasoning: str | None
status: str = "success"
agent_outputs: list[dict] | None = None
agent_weights: dict | None = None
created_at: datetime
actual_home_goals: int | None
actual_away_goals: int | None
+121
View File
@@ -0,0 +1,121 @@
"""生产环境启动安全校验:缺失关键配置则拒绝启动(生产)或警告(开发)。
校验项:
- SECRET_KEY 非空且非弱默认值
- 鉴权已配置(密码哈希 / .env 明文密码 / API Key 任一)
- DATABASE_URL 不使用示例弱密码(football:football)
与 deps.py 的 fail-closed 互补:此处是「启动时一次性校验 + 明确报错」,
避免生产带着危险配置上线却只在被攻击时才暴露。
"""
from __future__ import annotations
import logging
import sys
from src.core.config import settings
from src.core.runtime_config import get_admin_password_hash
logger = logging.getLogger(__name__)
# 明显的弱 SECRET_KEY 黑名单(大小写无关)
_WEAK_SECRET_KEYS = {
"", "changeme", "secret", "password", "123456", "admin",
"default", "dev", "development", "test", "example",
"openssl rand -base64 32", # 有人把生成指令直接粘进去
}
_MIN_SECRET_KEY_LEN = 16
# 示例弱数据库密码(仅识别最明显的;自定义强密码不受影响)
_WEAK_DB_PATTERNS = ("football:football@", "admin:admin@", "password@", "123456@")
class SecurityCheckError(Exception):
"""生产环境安全校验失败。"""
async def _auth_configured() -> bool:
"""运行时鉴权是否已配置(含数据库密码哈希/.env 明文/API Key)。"""
if await get_admin_password_hash():
return True
if settings.ADMIN_PASSWORD or settings.ADMIN_API_KEY:
return True
return False
def _check_secret_key() -> list[str]:
"""返回 SECRET_KEY 的问题列表(空=通过)。"""
problems: list[str] = []
key = settings.SECRET_KEY
if not key:
problems.append("SECRET_KEY 未设置,加密与会话签名无法保障")
return problems
if key.lower().strip() in _WEAK_SECRET_KEYS:
problems.append(f"SECRET_KEY 为弱默认值({key[:20]}...),请生成强随机值: openssl rand -base64 32")
elif len(key) < _MIN_SECRET_KEY_LEN:
problems.append(f"SECRET_KEY 过短({len(key)} 字符),建议至少 {_MIN_SECRET_KEY_LEN}")
return problems
def _check_database_url() -> list[str]:
problems: list[str] = []
url = settings.DATABASE_URL.lower()
for pat in _WEAK_DB_PATTERNS:
if pat in url:
problems.append(f"DATABASE_URL 使用示例弱密码({pat.rstrip('@')}),生产环境必须更换")
break
return problems
async def validate_security() -> dict:
"""执行安全校验。
返回 {"ok": bool, "errors": [...], "warnings": [...]}。
errors 为阻断性问题,warnings 为建议。
"""
errors: list[str] = []
warnings: list[str] = []
errors.extend(_check_secret_key())
if not await _auth_configured():
errors.append("管理鉴权未配置:请设置 ADMIN_PASSWORD 或 ADMIN_API_KEY")
warnings.extend(_check_database_url())
# 生产环境:DB 弱密码也升级为阻断
if settings.APP_ENV == "production" and warnings:
errors.extend(warnings)
warnings = []
ok = not errors
return {"ok": ok, "errors": errors, "warnings": warnings}
async def assert_security_on_startup() -> None:
"""启动入口:生产环境校验失败则拒绝启动,开发环境仅警告。"""
result = await validate_security()
for w in result["warnings"]:
logger.warning("[security-check] %s", w)
if result["ok"]:
if result["warnings"]:
logger.warning("[security-check] 存在 %d 项警告,建议修复", len(result["warnings"]))
else:
logger.info("[security-check] 安全校验通过")
return
# 阻断
is_prod = settings.APP_ENV == "production"
level = logging.ERROR if is_prod else logging.WARNING
for e in result["errors"]:
logger.log(level, "[security-check] %s", e)
if is_prod:
logger.critical(
"[security-check] 生产环境安全校验失败,拒绝启动。请修复上述 %d 项问题后重试。",
len(result["errors"]),
)
# 明确退出,避免带着危险配置上线
sys.exit(1)
logger.warning("[security-check] 开发环境存在 %d 项问题(未阻断),请尽快修复", len(result["errors"]))
+1 -1
View File
@@ -35,7 +35,7 @@ def load_agent_prompt(name: str, version: str = "v1") -> str:
@dataclass
class AgentSpec:
"""领域专家 agent 定义。"""
name: str # h2h / form / standings / injuries / xg
name: str # h2h / form / home_away / injuries / stats
system_prompt: str # system message
slice_fn: object # async (header, before) -> str 切片函数
+21 -5
View File
@@ -97,9 +97,12 @@ class MultiPredictResult:
reasoning: str | None
agent_outputs: list[dict]
agent_weights: dict | None
status: str = "success"
context: str
latency_ms: int | None
raw: dict | None
latency_ms: int | None = None
prompt_tokens: int | None = None
completion_tokens: int | None = None
raw: dict | None = None
async def _agent_provider(agent_id: str, *, tier: str) -> LLMProvider:
@@ -158,7 +161,10 @@ async def run_specialists(
reports: list[AgentReport] = []
for spec, r in zip(SPECIALIST_SPECS, results):
if isinstance(r, Exception):
logger.warning("agent %s raised: %s", spec.name, r)
logger.warning(
"专家调用失败 match=%s agent=%s error=%s",
header.match_id, spec.name, str(r)[:120],
)
reports.append(AgentReport(agent=spec.name, status="error", analysis=str(r)[:200]))
else:
reports.append(r)
@@ -256,8 +262,8 @@ async def predict_match_multi(
else:
# 所有专家无数据/均失败:跳过终裁,标记 degraded
logger.warning(
"match %s: 所有 %d 位专家均无有效数据,跳过终裁,标记 degraded",
match_id, len(reports),
"预测降级 match=%s mode=%s status=degraded experts=%d/%d 均无有效数据",
match_id, "multi", ok_reports, len(reports),
)
# 无有效专家时不调用 aggregator provider,避免多余开销
# model 使用 settings 默认值占位(无实际 LLM 调用)
@@ -337,6 +343,13 @@ async def predict_match_multi(
},
)
logger.info(
"预测完成 match=%s mode=%s status=%s pred=%s:%s (%s) latency=%sms, experts=%d/%d, prediction_id=%s",
match_id, "multi", pred_status,
pred.pred_home_goals, pred.pred_away_goals, pred.pred_1x2,
latency_ms, ok_reports, len(reports), pred.id,
)
return MultiPredictResult(
prediction_id=pred.id,
provider=pred.provider,
@@ -350,9 +363,12 @@ async def predict_match_multi(
pred_1x2=pred.pred_1x2,
subjective_confidence=pred.subjective_confidence,
reasoning=pred.reasoning,
status=pred_status,
agent_outputs=pred.agent_outputs,
agent_weights=agent_weights,
context=_reports_to_json(reports),
latency_ms=latency_ms,
prompt_tokens=pred.prompt_tokens,
completion_tokens=pred.completion_tokens,
raw=final,
)
+13
View File
@@ -70,6 +70,8 @@ class BacktestSummary:
"""回测汇总统计。"""
total: int
scored: int
success: int = 0 # status=success 的预测数(有完整比分+1x2)
degraded: int = 0 # status=degraded 的预测数(专家失败/无有效数据)
accuracy_1x2: float | None = None
avg_score_rmse: float | None = None
avg_subjective_confidence: float | None = None
@@ -194,6 +196,17 @@ async def run_backtest(
if r is not None:
summary.results.append(r)
summary.scored += 1
# success:有完整预测比分+1x2;degraded:多专家模式无有效结论
if r.pred_1x2 is not None and r.pred_home is not None and r.pred_away is not None:
summary.success += 1
else:
summary.degraded += 1
logger.info(
"回测汇总 mode=%s total=%d scored=%d success=%d accuracy=%s%%",
mode, summary.total, summary.scored, summary.success,
f"{(sum(1 for r in summary.results if r.correct_1x2) / summary.scored * 100):.1f}" if summary.scored else "n/a",
)
# 汇总统计
if summary.scored > 0:
+113
View File
@@ -0,0 +1,113 @@
"""极简基线预测:主客场场均进球估计(不调用 LLM,不产生费用)。
用于与 LLM 预测做 eval 对比。这是最简单的统计基线,仅供研究参考,
文档与 reasoning 均明确标注「非投注建议」。
"""
from __future__ import annotations
import logging
from datetime import datetime
from sqlalchemy import case, func, select
from src.db.base import AsyncSession, AsyncSessionLocal
from src.db.models import Match
logger = logging.getLogger(__name__)
async def _avg_goals(
db: AsyncSession,
*,
team_id: int,
side: str,
league_id: int,
before: datetime | None,
) -> float:
"""某队在该联赛已完赛场次的场均进球(side=home/away)。"""
if side == "home":
goals_col = Match.home_goals
team_col = Match.home_team_id
else:
goals_col = Match.away_goals
team_col = Match.away_team_id
stmt = (
select(func.avg(goals_col).label("avg_goals"), func.count().label("cnt"))
.where(
Match.match_status == "finished",
team_col == team_id,
Match.league_id == league_id,
goals_col.is_not(None),
)
)
if before is not None:
stmt = stmt.where(Match.match_date < before)
row = (await db.execute(stmt)).one()
return float(row.avg_goals) if row.avg_goals is not None and row.cnt > 0 else 0.0
async def predict_baseline(
match_id: int,
*,
backtest: bool = False,
cutoff_at: datetime | None = None,
) -> dict:
"""极简基线预测:主场场均进球 vs 客场场均进球。
返回与 PredictResult 兼容的字典:
provider=model="baseline", 不调用 LLM,latency_ms≈0。
"""
async with AsyncSessionLocal() as db:
match = await db.get(Match, match_id)
if match is None:
raise ValueError(f"match {match_id} not found")
before = None
if backtest and match.match_dt:
from datetime import timedelta
before = match.match_dt - timedelta(days=1)
elif cutoff_at is not None:
before = cutoff_at
home_avg = await _avg_goals(
db, team_id=match.home_team_id, side="home",
league_id=match.league_id, before=before,
)
away_avg = await _avg_goals(
db, team_id=match.away_team_id, side="away",
league_id=match.league_id, before=before,
)
pred_home = max(0, min(10, round(home_avg)))
pred_away = max(0, min(10, round(away_avg)))
# 主场轻微加成(可选,这里保持极简不额外加权)
if pred_home > pred_away:
pred_1x2 = "1"
elif pred_home < pred_away:
pred_1x2 = "2"
else:
pred_1x2 = "X"
return {
"pred_home_goals": float(pred_home),
"pred_away_goals": float(pred_away),
"alt_pred_home_goals": None,
"alt_pred_away_goals": None,
"pred_1x2": pred_1x2,
"subjective_confidence": 0.5,
"prompt_tokens": 0,
"completion_tokens": 0,
"reasoning": (
f"基线估计(非投注建议): 主队主场场均进球 {home_avg:.2f} → 预测 {pred_home}; "
f"客队客场场均进球 {away_avg:.2f} → 预测 {pred_away}"
),
"provider": "baseline",
"model": "baseline",
"prompt_version": "baseline_v1",
"mode": "baseline",
"status": "success",
"latency_ms": 0,
"raw": {"home_avg": round(home_avg, 2), "away_avg": round(away_avg, 2)},
}
+43 -9
View File
@@ -25,6 +25,10 @@ async def settle_prediction(prediction_id: int, home_goals: int, away_goals: int
pred.actual_home_goals = home_goals
pred.actual_away_goals = away_goals
pred.settled = True
logger.info(
"结算完成 prediction_id=%s match=%s actual=%s:%s mode=%s",
prediction_id, pred.match_id, home_goals, away_goals, pred.mode or "single",
)
return pred
@@ -109,8 +113,14 @@ async def get_eval_summary(
rows = list((await session.execute(stmt)).scalars().all())
from collections import defaultdict
buckets: dict[tuple[str, str], dict] = defaultdict(lambda: {
buckets: dict[tuple[str, str, str], dict] = defaultdict(lambda: {
"total": 0, "correct_1x2": 0, "score_errors": [], "conf_sum": 0.0, "conf_count": 0,
# 置信度校准分桶(仅 settled 且 pred 完整者计入)
"conf_buckets": {
"low(0-0.5)": {"total": 0, "correct": 0},
"medium(0.5-0.7)": {"total": 0, "correct": 0},
"high(0.7-1)": {"total": 0, "correct": 0},
},
})
evaluated = 0
skipped_incomplete = 0
@@ -118,35 +128,59 @@ async def get_eval_summary(
if (p.pred_home_goals is None or p.pred_away_goals is None or p.pred_1x2 is None):
skipped_incomplete += 1
continue
key = (p.provider, p.model)
key = (p.provider, p.model, p.prompt_version or "")
b = buckets[key]
b["total"] += 1
evaluated += 1
if p.actual_home_goals is None or p.actual_away_goals is None:
continue
actual = _actual_1x2(p.actual_home_goals, p.actual_away_goals)
if p.pred_1x2 == actual:
b["correct_1x2"] += 1
if p.pred_home_goals is not None and p.pred_away_goals is not None:
correct = False
if p.actual_home_goals is not None and p.actual_away_goals is not None:
actual = _actual_1x2(p.actual_home_goals, p.actual_away_goals)
if p.pred_1x2 == actual:
b["correct_1x2"] += 1
correct = True
if (
p.pred_home_goals is not None and p.pred_away_goals is not None
and p.actual_home_goals is not None and p.actual_away_goals is not None
):
err = ((p.pred_home_goals - p.actual_home_goals) ** 2 +
(p.pred_away_goals - p.actual_away_goals) ** 2) ** 0.5
b["score_errors"].append(err)
if p.subjective_confidence is not None:
b["conf_sum"] += p.subjective_confidence
b["conf_count"] += 1
# 仅当有实际结果可用于校准时,才落入置信度分桶
if p.actual_home_goals is not None and p.actual_away_goals is not None:
conf = p.subjective_confidence
if conf < 0.5:
bucket = "low(0-0.5)"
elif conf < 0.7:
bucket = "medium(0.5-0.7)"
else:
bucket = "high(0.7-1)"
b["conf_buckets"][bucket]["total"] += 1
if correct:
b["conf_buckets"][bucket]["correct"] += 1
summary = []
for (prov, model), b in sorted(buckets.items()):
for (prov, model, ver), b in sorted(buckets.items()):
acc = (b["correct_1x2"] / b["total"] * 100) if b["total"] else 0
avg_err = (sum(b["score_errors"]) / len(b["score_errors"])) if b["score_errors"] else None
avg_conf = (b["conf_sum"] / b["conf_count"]) if b["conf_count"] else None
# 校准分桶 → 命中率
calibration = {}
for name, cb in b["conf_buckets"].items():
hit_rate = round(cb["correct"] / cb["total"] * 100, 1) if cb["total"] else None
calibration[name] = {"total": cb["total"], "hit_rate": hit_rate}
summary.append({
"provider": prov,
"model": model,
"prompt_version": ver or None,
"total": b["total"],
"accuracy_1x2": round(acc, 1),
"avg_score_rmse": round(avg_err, 2) if avg_err is not None else None,
"avg_subjective_confidence": round(avg_conf, 2) if avg_conf is not None else None,
"calibration": calibration,
})
return {
"summary": summary,
+21 -6
View File
@@ -101,8 +101,9 @@ class PredictResult:
subjective_confidence: float | None
reasoning: str | None
context: str
latency_ms: int | None
raw: dict | None
status: str = "success"
latency_ms: int | None = None
raw: dict | None = None
async def _upsert_prediction(
@@ -158,15 +159,22 @@ async def predict_match(
backtest: bool = False,
cutoff_at=None,
) -> "PredictResult | MultiPredictResult":
"""预测入口。mode=multi(默认)走多 agent;mode=single 走单次调用。
"""预测入口。mode=multi(默认)走多 agent;mode=single 走单次调用;mode=baseline 走无 LLM 基线
Args:
use_cache: 是否允许返回进程内缓存结果。回测必须传 False——
mode: multi(默认,5 专家+终裁) / single(单次) / baseline(极简统计基线,不调用 LLM)。
use_cache:是否允许返回进程内缓存结果。回测必须传 False——
缓存命中不会新建 prediction 行,调用方会对同一个 prediction_id
反复 settle,把不同比赛的真实比分覆盖到同一条记录上。
backtest: 是否回测模式。True 时 cutoff 自动设为 match_date-1天。
cutoff_at: 显式截止时间,优先级高于 backtest 自动计算。
backtest:是否回测模式。True 时 cutoff 自动设为 match_date-1天。
cutoff_at:显式截止时间,优先级高于 backtest 自动计算。
"""
if mode == "baseline":
from src.llm.baseline import predict_baseline
return await predict_baseline(
match_id, backtest=backtest, cutoff_at=cutoff_at,
)
if mode == "single":
return await _predict_single(
match_id,
@@ -300,6 +308,7 @@ async def _predict_single(
pred_1x2=pred.pred_1x2,
subjective_confidence=pred.subjective_confidence,
reasoning=pred.reasoning,
status=pred.status,
context=ctx.text,
latency_ms=resp.latency_ms,
raw=resp.raw,
@@ -308,4 +317,10 @@ async def _predict_single(
# 5. 写入缓存(仅当允许缓存时)
if use_cache:
_set_cached(match_id, settings.LLM_PROVIDER, provider.model, version, tpl_hash, result)
logger.info(
"预测完成 match=%s mode=%s status=%s pred=%s:%s (%s) latency=%sms",
match_id, "single", "success",
validated.pred_home_goals, validated.pred_away_goals, validated.pred_1x2,
resp.latency_ms,
)
return result
-1
View File
@@ -6,7 +6,6 @@
1. 主客队近期状态差异
2. 主客场因素
3. 历史交锋心理优势
4. 联赛排名差距
严格按此 JSON 输出,不要其他内容:
```json
+29
View File
@@ -0,0 +1,29 @@
{
"_note": "脱敏样例:球队名/日期/ID 已替换为占位符,字段结构对齐真实 bzzoiro 响应。待获取真实响应后替换。",
"id": "evt_placeholder_001",
"event_date": "2026-09-12T19:00:00+00:00",
"status": "finished",
"league": { "id": 1, "code": "E0", "name": "Premier League" },
"season": "2026-2027",
"round_number": 5,
"round_name": null,
"home_team": "Home United FC",
"away_team": "Away City FC",
"home_score": 2,
"away_score": 1,
"home_score_ht": 1,
"away_score_ht": 0,
"home_shots": 14,
"away_shots": 8,
"home_shots_on_target": 5,
"away_shots_on_target": 3,
"home_corners": 6,
"away_corners": 4,
"home_possession": 58.5,
"home_xg": 1.85,
"away_xg": 0.92,
"home_yellow_cards": 2,
"away_yellow_cards": 3,
"home_red_cards": 0,
"away_red_cards": 0
}
+189
View File
@@ -0,0 +1,189 @@
"""FastAPI 关键路径测试:鉴权、限流、游标方向。
运行(需先 pip-sync requirements-dev.txt):
pytest tests/test_api_critical.py -v
设计:
- 鉴权:直接测 require_admin / auth_configured 逻辑,monkeypatch 切换环境,
避免启动完整 app lifespan(异步 DB 引擎与同步 TestClient 不兼容)。
- 限流:直接测 _RateLimiter 单元。
- 游标:直接构造 SQL 验证 scheduled ASC / 其他 DESC 方向。
- 不依赖真实 LLM / 数据库:纯逻辑测试。
"""
from __future__ import annotations
import asyncio
from unittest.mock import AsyncMock, patch
import pytest
from src.api import deps
from src.api.deps import (
_RateLimiter,
auth_configured,
require_admin,
)
from src.core import runtime_config
# ── 1. 鉴权:fail-closed(生产) vs fail-open(开发) ─────────────────────
class TestAuthFailClosed:
"""未配置鉴权策略时,production 环境应拒绝(503),development 应放行。"""
@pytest.mark.asyncio
async def test_production_no_auth_returns_503(self):
"""APP_ENV=production + 未配置任何鉴权 → require_admin 抛 503。"""
from fastapi import HTTPException
from fastapi.requests import Request
request = Request(scope={"type": "http", "client": ("1.2.3.4", 0), "cookies": {}, "headers": []})
with patch.object(deps, "settings") as s, \
patch("src.api.deps.auth_configured", AsyncMock(return_value=False)):
s.REQUIRE_ADMIN_AUTH = False
s.APP_ENV = "production"
s.ADMIN_API_KEY = ""
s.ADMIN_PASSWORD = ""
with pytest.raises(HTTPException) as exc:
await require_admin(request, x_api_key=None)
assert exc.value.status_code == 503
@pytest.mark.asyncio
async def test_development_no_auth_passes(self):
"""APP_ENV=development + 未配置鉴权 → 放行(只打 warning)。"""
from fastapi.requests import Request
request = Request(scope={"type": "http", "client": ("1.2.3.4", 0), "cookies": {}, "headers": []})
with patch.object(deps, "settings") as s, \
patch("src.api.deps.auth_configured", AsyncMock(return_value=False)):
s.REQUIRE_ADMIN_AUTH = False
s.APP_ENV = "development"
s.ADMIN_API_KEY = ""
s.ADMIN_PASSWORD = ""
# 不应抛异常
await require_admin(request, x_api_key=None)
@pytest.mark.asyncio
async def test_require_admin_key_valid(self):
"""配置 ADMIN_API Key 后,带正确 X-API-Key 头 → 通过。"""
from fastapi.requests import Request
request = Request(scope={"type": "http", "client": ("1.2.3.4", 0), "cookies": {},
"headers": [(b"x-api-key", b"test-secret-key")]})
with patch.object(deps, "settings") as s, \
patch("src.core.runtime_config.get_admin_password_hash", AsyncMock(return_value="")):
s.REQUIRE_ADMIN_AUTH = True
s.APP_ENV = "production"
s.ADMIN_API_KEY = "test-secret-key"
# 不应抛异常
await require_admin(request, x_api_key="test-secret-key")
@pytest.mark.asyncio
async def test_require_admin_key_invalid(self):
"""API Key 错误 → 401。"""
from fastapi import HTTPException
from fastapi.requests import Request
request = Request(scope={"type": "http", "client": ("1.2.3.4", 0), "cookies": {},
"headers": [(b"x-api-key", b"wrong")]})
with patch.object(deps, "settings") as s, \
patch("src.core.runtime_config.get_admin_password_hash", AsyncMock(return_value="")):
s.REQUIRE_ADMIN_AUTH = True
s.APP_ENV = "production"
s.ADMIN_API_KEY = "test-secret-key"
with pytest.raises(HTTPException) as exc:
await require_admin(request, x_api_key="wrong")
assert exc.value.status_code == 401
# ── 2. 限流:滑动窗口逻辑 ─────────────────────────────────────────────
class TestRateLimit:
"""预测限流:_RateLimiter 单元测试。"""
def test_rate_limit_triggers_after_max(self):
"""max=2/60s → 第 3 次被拒。"""
limiter = _RateLimiter(max_requests=2, window_seconds=60)
ip = "1.2.3.4"
assert limiter.is_allowed(ip) is True
assert limiter.is_allowed(ip) is True
assert limiter.is_allowed(ip) is False # 超限
assert limiter.remaining(ip) == 0
def test_rate_limit_remaining_decrements(self):
"""剩余配额计算准确。"""
limiter = _RateLimiter(max_requests=5, window_seconds=60)
ip = "5.6.7.8"
assert limiter.remaining(ip) == 5
limiter.is_allowed(ip)
limiter.is_allowed(ip)
assert limiter.remaining(ip) == 3
def test_rate_limit_per_ip_isolated(self):
"""不同 IP 独立计数。"""
limiter = _RateLimiter(max_requests=1, window_seconds=60)
assert limiter.is_allowed("1.1.1.1") is True
assert limiter.is_allowed("1.1.1.1") is False # 同 IP 超限
assert limiter.is_allowed("2.2.2.2") is True # 不同 IP 不受影响
# ── 3. 游标方向:scheduled ASC 用 > ───────────────────────────────────
class TestCursorDirection:
"""验证 scheduled 状态查询时排序方向为 ASC(使用 > 游标)。"""
def test_scheduled_uses_ascending_order(self):
"""scheduled → match_date ASC(最近的未开赛排最前)。"""
from src.api.routes import matches as matches_mod
from sqlalchemy import select
q = select(matches_mod.Match).where(matches_mod.Match.match_status == "scheduled")
q = q.order_by(matches_mod.Match.match_date.asc(), matches_mod.Match.id.asc())
sql = str(q)
assert "ORDER BY matches.match_date ASC" in sql, sql
def test_finished_uses_descending_order(self):
"""finished/其他 → DESC(最新赛果在前)。"""
from src.api.routes import matches as matches_mod
from sqlalchemy import select
q = select(matches_mod.Match).where(matches_mod.Match.match_status == "finished")
q = q.order_by(matches_mod.Match.match_date.desc(), matches_mod.Match.id.desc())
sql = str(q)
assert "ORDER BY matches.match_date DESC" in sql, sql
# ── 评估置信度校准分桶 ─────────────────────────────────────────────
# ── 评估置信度校准分桶 ─────────────────────────────────────────────
class TestEvalCalibration:
"""验证 settled 预测按置信度分桶统计命中率。"""
def test_confidence_bucketing(self):
"""置信度落入正确的桶。"""
assert _bucket_key(0.3) == "low(0-0.5)"
assert _bucket_key(0.5) == "medium(0.5-0.7)"
assert _bucket_key(0.6) == "medium(0.5-0.7)"
assert _bucket_key(0.7) == "high(0.7-1)"
assert _bucket_key(0.95) == "high(0.7-1)"
def _bucket_key(conf: float) -> str:
if conf < 0.5:
return "low(0-0.5)"
elif conf < 0.7:
return "medium(0.5-0.7)"
else:
return "high(0.7-1)"
+130
View File
@@ -0,0 +1,130 @@
"""测试极简基线预测:不调用 LLM,基于主客场场均进球估计,写入 prediction 表。
运行(需先 pip-sync requirements-dev.txt):
pytest tests/test_baseline.py -v
"""
from __future__ import annotations
from unittest.mock import patch
import pytest
from src.llm.baseline import _avg_goals, predict_baseline
@pytest.mark.asyncio
async def test_avg_goals_no_data_returns_zero():
"""无历史数据时场均进球为 0(不抛异常)。"""
class FakeRow:
avg_goals = None
cnt = 0
class FakeResult:
def one(self):
return FakeRow()
class FakeSession:
async def execute(self, stmt):
return FakeResult()
avg = await _avg_goals(FakeSession(), team_id=1, side="home", league_id=1, before=None)
assert avg == 0.0
@pytest.mark.asyncio
async def test_avg_goals_with_data():
"""有数据时返回正确均值。"""
class FakeRow:
avg_goals = 1.5
cnt = 10
class FakeResult:
def one(self):
return FakeRow()
class FakeSession:
async def execute(self, stmt):
return FakeResult()
avg = await _avg_goals(FakeSession(), team_id=1, side="home", league_id=1, before=None)
assert avg == 1.5
@pytest.mark.asyncio
async def test_predict_baseline_no_llm():
"""基线预测不调用 LLM(provider=model=baseline),latency_ms=0。"""
captured = {}
async def fake_avg(db, *, team_id, side, league_id, before):
captured[f"{side}_{team_id}"] = True
return 2.4 if side == "home" else 1.6
class FakeMatch:
id = 1
match_id = 1
home_team_id = 10
away_team_id = 20
league_id = 1
match_status = "scheduled"
with patch("src.llm.baseline._avg_goals", fake_avg), \
patch("src.llm.baseline.AsyncSessionLocal") as SLC:
class FakeSession:
async def get(self, cls, mid):
return FakeMatch()
class FakeCM:
async def __aenter__(self):
return FakeSession()
async def __aexit__(self, *a):
return None
SLC.return_value = FakeCM()
result = await predict_baseline(1)
assert result["provider"] == "baseline"
assert result["model"] == "baseline"
assert result["mode"] == "baseline"
assert result["latency_ms"] == 0
assert result["prompt_tokens"] == 0
assert result["completion_tokens"] == 0
# 2.4 → round = 2, 1.6 → round = 2 → 平局 X
assert result["pred_home_goals"] == 2.0
assert result["pred_away_goals"] == 2.0
assert result["pred_1x2"] == "X"
assert result["subjective_confidence"] == 0.5
assert "非投注建议" in result["reasoning"]
# 确认未调用任何 LLM 相关模块
assert "home_10" in captured and "away_20" in captured
@pytest.mark.asyncio
async def test_predict_baseline_clamps_to_range():
"""预测进球数裁剪到 [0, 10]。"""
async def fake_avg(db, *, team_id, side, league_id, before):
return 15.0 if side == "home" else -3.0
class FakeMatch:
id = 2
match_id = 2
home_team_id = 10
away_team_id = 20
league_id = 1
match_status = "scheduled"
with patch("src.llm.baseline._avg_goals", fake_avg), \
patch("src.llm.baseline.AsyncSessionLocal") as SLC:
class FakeSession:
async def get(self, cls, mid):
return FakeMatch()
class FakeCM:
async def __aenter__(self):
return FakeSession()
async def __aexit__(self, *a):
return None
SLC.return_value = FakeCM()
result = await predict_baseline(2)
assert result["pred_home_goals"] == 10.0 # clamped
assert result["pred_away_goals"] == 0.0 # clamped
assert result["pred_1x2"] == "1" # 10:0 主胜
+176
View File
@@ -0,0 +1,176 @@
"""测试 bzzoiro 事件规范化:基于 fixtures/bzzoiro_event.json 的真实字段映射。
运行(需先 pip-sync requirements-dev.txt):
pytest tests/test_bzzoiro_normalize.py -v
若真实 bzzoiro 字段名与样例不同,断言会失败 —— 这正是本测试的目的:
锁定 normalize_bzzoiro 所依赖的字段名,避免上游静默变更导致数据丢失。
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from src.data.config import LEAGUE_NAMES
from src.data.normalize import normalize_bzzoiro
FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures"
def load_event() -> dict:
with open(FIXTURE_DIR / "bzzoiro_event.json", encoding="utf-8") as f:
return json.load(f)
class TestBzzoiroNormalize:
"""验证 normalize_bzzoiro 对 fixture 样例的解析结果。"""
def test_basic_fields(self):
"""基础字段:日期、状态、对阵、进球。"""
m = normalize_bzzoiro(load_event(), "E0")
assert m is not None
assert m.home_team == "Home United FC"
assert m.away_team == "Away City FC"
assert m.match_status == "finished"
assert m.home_goals == 2
assert m.away_goals == 1
assert m.home_ht_goals == 1
assert m.away_ht_goals == 0
def test_shots_mapping(self):
"""射门数映射到 home_shots / away_shots。"""
m = normalize_bzzoiro(load_event(), "E0")
assert m.home_shots == 14
assert m.away_shots == 8
def test_shots_on_target_mapping(self):
"""射正数映射到 home_shots_on_target / away_shots_on_target。"""
m = normalize_bzzoiro(load_event(), "E0")
assert m.home_shots_on_target == 5
assert m.away_shots_on_target == 3
def test_corners_mapping(self):
"""角球映射。"""
m = normalize_bzzoiro(load_event(), "E0")
assert m.home_corners == 6
assert m.away_corners == 4
def test_possession_mapping(self):
"""控球率:API 提供 home 值。"""
m = normalize_bzzoiro(load_event(), "E0")
assert m.home_possession == 58.5
def test_xg_mapping(self):
"""xG 映射到 home_xg / away_xg。"""
m = normalize_bzzoiro(load_event(), "E0")
assert m.home_xg == 1.85
assert m.away_xg == 0.92
def test_cards_mapping(self):
"""黄牌、红牌映射。"""
m = normalize_bzzoiro(load_event(), "E0")
assert m.home_yellow_cards == 2
assert m.away_yellow_cards == 3
assert m.home_red_cards == 0
assert m.away_red_cards == 0
def test_fallback_aliases(self):
"""回退别名:shots_home → home_shots, xg_home → home_xg。"""
raw = {
"event_date": "2026-09-12T19:00:00+00:00",
"status": "finished",
"home_team": "FC Alpha",
"away_team": "FC Beta",
"home_goals": 1,
"away_goals": 1,
"shots_home": 10, "shots_away": 5,
"sot_home": 4, "sot_away": 2,
"corners_home": 3, "corners_away": 2,
"possession": 55.0,
"xg_home": 1.2, "xg_away": 0.8,
"yellow_cards_home": 1, "yellow_cards_away": 2,
"red_cards_home": 0, "red_cards_away": 1,
}
m = normalize_bzzoiro(raw, "SP1")
assert m is not None
assert m.home_shots == 10
assert m.away_shots == 5
assert m.home_shots_on_target == 4
assert m.away_shots_on_target == 2
assert m.home_corners == 3
assert m.away_corners == 2
assert m.home_possession == 55.0
assert m.home_xg == 1.2
assert m.away_xg == 0.8
assert m.home_yellow_cards == 1
assert m.away_yellow_cards == 2
assert m.home_red_cards == 0
assert m.away_red_cards == 1
def test_missing_stats_still_normalizes(self):
"""缺少统计字段时仍应解析基础数据,统计字段为 None(不伪造)。"""
raw = {
"event_date": "2026-09-12T19:00:00+00:00",
"status": "finished",
"home_team": "FC One",
"away_team": "FC Two",
"home_goals": 3,
"away_goals": 0,
}
m = normalize_bzzoiro(raw, "D1")
assert m is not None
assert m.home_goals == 3
assert m.away_goals == 0
# 无数据字段保持 None,不伪造
assert m.home_shots is None
assert m.home_xg is None
assert m.home_possession is None
def test_unknown_status_drops(self):
"""未知状态 → 丢弃(None)。"""
raw = {
"event_date": "2026-09-12T19:00:00+00:00",
"status": "weird_status",
"home_team": "A",
"away_team": "B",
}
assert normalize_bzzoiro(raw, "E0") is None
def test_invalid_date_drops(self):
"""无效日期 → 丢弃(None)。"""
raw = {
"event_date": "not-a-date",
"status": "finished",
"home_team": "A",
"away_team": "B",
"home_goals": 1,
"away_goals": 0,
}
assert normalize_bzzoiro(raw, "E0") is None
def test_same_team_drops(self):
"""主客队同名(规范化后) → 丢弃(None)。"""
raw = {
"event_date": "2026-09-12T19:00:00+00:00",
"status": "finished",
"home_team": "Same FC",
"away_team": "Same FC",
"home_goals": 1,
"away_goals": 0,
}
assert normalize_bzzoiro(raw, "E0") is None
# ── 真实响应校验(占位,待替换后取消 skip) ─────────────────────────
@pytest.mark.skip(reason="待提供真实 bzzoiro event 响应后替换 fixture 并取消 skip")
def test_real_response_matches_fixture_structure():
"""真实响应应能被 fixture 结构覆盖(字段名一致)。"""
# 真实响应粘贴于此,验证 normalize_bzzoiro 解析成功
real_response = {}
if not real_response:
pytest.skip("未提供真实响应")
m = normalize_bzzoiro(real_response, "E0")
assert m is not None
+71
View File
@@ -0,0 +1,71 @@
"""验证就绪探针:数据库不可用时 /health/ready 返回 503 而非 200。
运行方式(在宿主机上):
python tests/test_health_ready.py
脚本经本地 8000 端口直调 API,通过启停 postgres 容器验证:
- 健康时返回 HTTP 200
- postgres 停止后返回 HTTP 503(不再误报 200)
- postgres 恢复后回到 200
"""
from __future__ import annotations
import json
import subprocess
import sys
import time
import urllib.request
BASE = "http://localhost:8000"
def api_status() -> tuple[int, dict]:
try:
with urllib.request.urlopen(f"{BASE}/health/ready", timeout=5) as r:
return r.status, json.loads(r.read())
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read())
def compose(*args: str) -> None:
subprocess.run(["docker", "compose", *args], check=False, capture_output=True)
def wait_for(target: int, timeout: int = 30) -> bool:
deadline = time.time() + timeout
while time.time() < deadline:
try:
code, _ = api_status()
if code == target:
return True
except Exception:
pass
time.sleep(1)
return False
def main() -> int:
code, _ = api_status()
if code != 200:
print(f"FAIL: 初始状态期望 200,得到 {code}"); return 1
print(f"PASS: 健康时 HTTP 200")
compose("stop", "postgres")
try:
if not wait_for(503, timeout=30):
print("FAIL: postgres 停止后未返回 503"); return 1
print("PASS: postgres 停止后 HTTP 503(就绪探针正确拒绝)")
finally:
compose("start", "postgres")
if not wait_for(200, timeout=30):
print("FAIL: postgres 恢复后未回到 200"); return 1
print("PASS: postgres 恢复后 HTTP 200")
print("ALL PASS")
return 0
if __name__ == "__main__":
sys.exit(main())
+23 -87
View File
@@ -1,89 +1,25 @@
"""回归测试: 比赛列表游标页方向修复
"""验证: matches 游标页方向正确且无重复 id
验证:
- status=scheduled 时,游标条件为「大于」(ASC 方向)
- 其它 status 时,游标条件为「小于」(DESC 方向)
- 无 cursor 时行为不变
scheduled 升序游标条件必须为 > 而非 <;翻页返回的 id 集合无重复。
端到端验证脚本(容器内运行,通过 nginx 代理):
python - <<'PY'
import urllib.parse, urllib.request, json
base = "http://localhost:3000/api/v1/matches?league=E0&status=scheduled&limit=50&cursor="
seen = set(); cursor = None; pages = 0
while True:
url = base + ("" if cursor is None else urllib.parse.quote(cursor, safe=""))
d = json.load(urllib.request.urlopen(url))
ids = [m["id"] for m in d["items"]]
dup = seen.intersection(ids)
assert not dup, f"{pages}出现重复id: {dup}"
seen.update(ids); pages += 1
if not d["has_more"] or not d["next_cursor"]: break
cursor = d["next_cursor"]
import subprocess
total = int(subprocess.check_output(
["psql","-U","football","-d","football","-tAc",
"SELECT count(*) FROM matches WHERE match_status='scheduled' AND league_id=(SELECT id FROM leagues WHERE code='E0')"]))
assert len(seen) == total, f"翻页得{len(seen)}条,库中{total}"
print(f"PASS: {pages}页共{len(seen)}条,无重复")
PY
"""
from __future__ import annotations
from datetime import datetime, timezone
import pytest
from sqlalchemy import select
from src.db.models import Match
class TestCursorPaginationDirection:
"""验证游标条件方向与排序方向一致。"""
def _build_query(self, status=None, cursor=None):
"""复现 list_matches 的查询构造逻辑,返回 where 条件列表。"""
q = select(Match)
if cursor:
last_date_str, last_id_str = cursor.split("|", 1)
last_date = datetime.fromisoformat(last_date_str)
last_id = int(last_id_str)
if status == "scheduled":
q = q.where(
(Match.match_date > last_date) |
((Match.match_date == last_date) & (Match.id > last_id))
)
else:
q = q.where(
(Match.match_date < last_date) |
((Match.match_date == last_date) & (Match.id < last_id))
)
if status:
q = q.where(Match.match_status == status)
if status == "scheduled":
order = (Match.match_date.asc(), Match.id.asc())
else:
order = (Match.match_date.desc(), Match.id.desc())
return q.order_by(*order)
def test_scheduled_uses_greater_than(self):
"""scheduled + cursor: 应使用 > 条件(ASC 方向)。"""
q = self._build_query(
status="scheduled",
cursor="2026-01-15T15:00:00|100"
)
sql = str(q)
assert ">" in sql, f"scheduled 游标应使用 >,SQL: {sql}"
assert "<" not in sql or "match_date <" not in sql, f"不应出现 < 条件"
def test_other_status_uses_less_than(self):
"""finished + cursor: 应使用 < 条件(DESC 方向)。"""
q = self._build_query(
status="finished",
cursor="2026-01-15T15:00:00|100"
)
sql = str(q)
assert "<" in sql, f"finished 游标应使用 <,SQL: {sql}"
assert "match_date >" not in sql, f"不应出现 > 条件"
def test_no_cursor_no_direction(self):
"""无 cursor 时不应有游标条件。"""
q = self._build_query(status="scheduled", cursor=None)
sql = str(q)
# 应无 match_date 比较条件(只有 status filter)
assert "match_date >" not in sql
assert "match_date <" not in sql
def test_scheduled_order_is_asc(self):
"""scheduled 排序应为 ASC。"""
q = self._build_query(status="scheduled", cursor=None)
sql = str(q)
assert "ASC" in sql, f"scheduled 应 ASC 排序,SQL: {sql}"
assert "DESC" not in sql, f"不应出现 DESC"
def test_finished_order_is_desc(self):
"""finished 排序应为 DESC。"""
q = self._build_query(status="finished", cursor=None)
sql = str(q)
assert "DESC" in sql, f"finished 应 DESC 排序,SQL: {sql}"
+128
View File
@@ -0,0 +1,128 @@
"""单测:生产环境启动安全校验。
覆盖:
- production 缺 SECRET_KEY → 阻断(sys.exit)
- production 缺鉴权 → 阻断
- production 全配置 → 通过
- development 缺配置 → 仅警告(不退出)
- DATABASE_URL 弱密码 → production 阻断 / development 仅警告
"""
from __future__ import annotations
import asyncio
import logging
from unittest.mock import AsyncMock, patch
import pytest
from src.core.config import Settings
from src.core.security_check import (
_WEAK_SECRET_KEYS,
_WEAK_DB_PATTERNS,
assert_security_on_startup,
validate_security,
)
def _base_settings(**overrides) -> Settings:
"""构造测试用 Settings,默认模拟一个"已合规"的基线。"""
defaults = dict(
APP_ENV="production",
SECRET_KEY="aSwLuw2mqoQdSKUfB3eFVfW2Tv7VnJRRixMxOwZZi5M=",
ADMIN_PASSWORD="",
ADMIN_API_KEY="",
DATABASE_URL="postgresql+asyncpg://user:StrongP@ssw0rd@db:5432/prod",
REQUIRE_ADMIN_AUTH=False,
)
defaults.update(overrides)
return Settings(**defaults)
class TestValidateSecurity:
"""validate_security 逻辑。"""
@pytest.mark.asyncio
async def test_production_fully_configured_passes(self):
with patch("src.core.security_check.settings", _base_settings()), \
patch("src.core.security_check._auth_configured", AsyncMock(return_value=True)):
result = await validate_security()
assert result["ok"] is True
assert result["errors"] == []
@pytest.mark.asyncio
async def test_production_missing_secret_key_fails(self):
with patch("src.core.security_check.settings", _base_settings(SECRET_KEY="")), \
patch("src.core.security_check._auth_configured", AsyncMock(return_value=True)):
result = await validate_security()
assert result["ok"] is False
assert any("SECRET_KEY" in e for e in result["errors"])
@pytest.mark.asyncio
async def test_production_weak_secret_key_fails(self):
with patch("src.core.security_check.settings", _base_settings(SECRET_KEY="changeme")), \
patch("src.core.security_check._auth_configured", AsyncMock(return_value=True)):
result = await validate_security()
assert result["ok"] is False
assert any("SECRET_KEY" in e for e in result["errors"])
@pytest.mark.asyncio
async def test_production_missing_auth_fails(self):
with patch("src.core.security_check.settings", _base_settings()), \
patch("src.core.security_check._auth_configured", AsyncMock(return_value=False)):
result = await validate_security()
assert result["ok"] is False
assert any("鉴权" in e or "ADMIN" in e for e in result["errors"])
@pytest.mark.asyncio
async def test_production_weak_db_password_fails(self):
with patch("src.core.security_check.settings",
_base_settings(DATABASE_URL="postgresql+asyncpg://football:football@localhost:5432/football")), \
patch("src.core.security_check._auth_configured", AsyncMock(return_value=True)):
result = await validate_security()
assert result["ok"] is False
assert any("DATABASE_URL" in e or "弱密码" in e for e in result["errors"])
@pytest.mark.asyncio
async def test_development_missing_config_only_warns(self):
"""development:缺 SECRET_KEY/鉴权 → errors 存在但弱 DB 密码不进 errors。"""
with patch("src.core.security_check.settings",
_base_settings(APP_ENV="development", SECRET_KEY="", ADMIN_API_KEY="",
DATABASE_URL="postgresql+asyncpg://football:football@localhost:5432/football")), \
patch("src.core.security_check._auth_configured", AsyncMock(return_value=False)):
result = await validate_security()
# development 下:弱 DB 密码只在 warnings,不会升级到 errors
assert result["ok"] is False # 仍有 errors(缺密钥 + 缺鉴权)
assert any("DATABASE_URL" in w for w in result["warnings"])
class TestAssertSecurityOnStartup:
"""assert_security_on_startup 退出行为。"""
@pytest.mark.asyncio
async def test_production_failure_exits(self):
with patch("src.core.security_check.settings", _base_settings(SECRET_KEY="")), \
patch("src.core.security_check._auth_configured", AsyncMock(return_value=True)):
with pytest.raises(SystemExit) as exc:
await assert_security_on_startup()
assert exc.value.code == 1
@pytest.mark.asyncio
async def test_production_pass_does_not_exit(self):
with patch("src.core.security_check.settings", _base_settings()), \
patch("src.core.security_check._auth_configured", AsyncMock(return_value=True)):
# 不应抛异常 / 退出
await assert_security_on_startup()
@pytest.mark.asyncio
async def test_development_failure_does_not_exit(self):
with patch("src.core.security_check.settings",
_base_settings(APP_ENV="development", SECRET_KEY="")), \
patch("src.core.security_check._auth_configured", AsyncMock(return_value=True)):
# development 即使有问题也不退出
await assert_security_on_startup()
def test_weak_secret_keys_list_not_empty():
"""防御性:弱密钥表应包含常见弱值。"""
assert "changeme" in _WEAK_SECRET_KEYS
assert "football:football@" in _WEAK_DB_PATTERNS