fix:批量修复了一些问题
This commit is contained in:
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 === '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' ? 'text-press' : 'text-ink-900'
|
||||
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 (
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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="暂无评估数据"
|
||||
/>
|
||||
)}
|
||||
|
||||
+106
-11
@@ -101,16 +101,27 @@ export interface CollectionRequest {
|
||||
|
||||
// ── 评估 & 回测 ─────────────────────────────────────────────────
|
||||
|
||||
export interface EvalSummary {
|
||||
summary: Array<{
|
||||
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<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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+433
-70
@@ -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,38 +464,67 @@ export default function Matches() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && matches.map(m => {
|
||||
{!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 active = predictionFor?.id === m.id
|
||||
const finished = m.match_status === 'finished'
|
||||
const expanded = expandedId === m.id
|
||||
const detail = detailMap[m.id]
|
||||
const ctx = contextMap[m.id]
|
||||
|
||||
// 展开时懒加载详情(只读,不触发 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={m.id}>
|
||||
{/* 行:可点击展开 */}
|
||||
<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' : ''
|
||||
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 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>
|
||||
|
||||
{/* 对阵:移动端主队/比分/客队同一行,桌面端 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">
|
||||
{/* 对阵行:小屏主队(弹性)/比分/客队(弹性)三格;桌面 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>
|
||||
</div>
|
||||
|
||||
{/* 比分 / VS */}
|
||||
<div className="flex w-14 flex-shrink-0 flex-col items-center sm:w-auto">
|
||||
</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}
|
||||
@@ -451,25 +532,35 @@ export default function Matches() {
|
||||
) : (
|
||||
<span className="text-2xs tracking-widest text-ink-400">VS</span>
|
||||
)}
|
||||
{m.home_xg !== null && m.away_xg !== null && (
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 客队(左对齐) */}
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
</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>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 状态列(桌面) */}
|
||||
{/* 预测按钮:小屏独占一行(桌面端 sm:contents 下隐藏) */}
|
||||
{!finished && (
|
||||
<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="flex justify-end">
|
||||
<div className="hidden sm:flex sm:justify-end" onClick={e => e.stopPropagation()}>
|
||||
{!finished && (
|
||||
<button
|
||||
onClick={() => predict(m)}
|
||||
@@ -481,10 +572,21 @@ export default function Matches() {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>{/* 关闭可点击行(clickable row) */}
|
||||
|
||||
{/* 展开详情面板(只读数据 + 预测按钮 + 历史预测 + 专家报告入口) */}
|
||||
{expanded && (
|
||||
<MatchDetailPanel
|
||||
match={m} detail={detail} ctx={ctx}
|
||||
loading={detailLoading === m.id}
|
||||
mode={mode}
|
||||
/>
|
||||
)}
|
||||
</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,7 +854,21 @@ function PredictionPanel({
|
||||
)}
|
||||
|
||||
<div className="space-y-7 px-4 py-6 sm:px-5">
|
||||
{/* ── 预测比分:版面核心,大号宋体 ── */}
|
||||
{/* ── 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>
|
||||
<p className="mt-1.5 whitespace-pre-wrap text-xs leading-relaxed text-ink-600">
|
||||
{prediction.reasoning || '所有专家均无有效数据或调用失败,无法生成可靠比分。'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 主结论(仅 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 ?? '-'}
|
||||
@@ -702,7 +876,7 @@ function PredictionPanel({
|
||||
{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 && (
|
||||
{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">
|
||||
@@ -712,53 +886,81 @@ function PredictionPanel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── 胜平负 ── */}
|
||||
<div className="border-y border-ink-200 py-4">
|
||||
<OutcomeLine pick={prediction.pred_1x2} confidence={prediction.subjective_confidence} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── 元信息一行 ── */}
|
||||
{/* ── 成本信息(耗时 + token + 限流余量) ── */}
|
||||
{!degraded && (
|
||||
<PredictionCost prediction={prediction} />
|
||||
)}
|
||||
|
||||
{/* ── 元信息 ── */}
|
||||
<p className="text-center text-2xs text-ink-500">
|
||||
{mode === 'multi' ? `多专家模式 · ${okReports.length}/${prediction.agent_outputs?.length ?? 0} 路有效` : '单次模式'}
|
||||
{mode === 'multi' ? `多专家模式 · ${okReports.length}/${reports.length} 路有效` : '单次模式'}
|
||||
{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 && (
|
||||
{/* ── 终裁/降级说明意见 ── */}
|
||||
{prediction.reasoning && degraded && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{prediction.agent_outputs.map((r, i) => (
|
||||
<AgentCard key={r.agent} report={r} no={CN_NUM[i] ?? String(i + 1)} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── 终裁意见:引文式,红竖线 ── */}
|
||||
{prediction.reasoning && (
|
||||
<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
@@ -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]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+1184
File diff suppressed because it is too large
Load Diff
+16
-3
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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"]))
|
||||
@@ -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 切片函数
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)},
|
||||
}
|
||||
+40
-6
@@ -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
|
||||
|
||||
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
|
||||
if p.pred_home_goals is not None and p.pred_away_goals is not None:
|
||||
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
@@ -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
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
1. 主客队近期状态差异
|
||||
2. 主客场因素
|
||||
3. 历史交锋心理优势
|
||||
4. 联赛排名差距
|
||||
|
||||
严格按此 JSON 输出,不要其他内容:
|
||||
```json
|
||||
|
||||
Vendored
+29
@@ -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
|
||||
}
|
||||
@@ -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)"
|
||||
@@ -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 主胜
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user