feat: 核心模块增强 — 加密 + 运行时配置 + 日志缓冲
- crypto.py: API Key 加密/解密工具 - runtime_config.py: 运行时动态配置管理 - log_buffer.py: 内存日志缓冲区 - config.py: 新增加密配置项 - http_client.py: 增强重试和错误处理
This commit is contained in:
+13
-2
@@ -27,7 +27,18 @@ API_FOOTBALL_KEY=
|
||||
# ---- CORS ----
|
||||
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||
|
||||
# ---- 加密主密钥 ----
|
||||
# 敏感配置(数据源/LLM API Key)入库加密与会话签名均由它派生。
|
||||
# 只存部署机 .env,切勿入库或提交;生成: openssl rand -base64 32
|
||||
# 变更后已加密配置无法解密,需在后台重新保存。
|
||||
SECRET_KEY=
|
||||
|
||||
# ---- 管理接口鉴权 ----
|
||||
# 采集/回测/回填接口的访问密钥(请求头 X-API-Key)。
|
||||
# 留空 = 不启用鉴权(本地开发默认);生产环境必须设置强随机值。
|
||||
# 管理后台登录密码(/admin 页面与采集/回测/回填等管理接口)。
|
||||
# 此处为初始值:启动时自动迁移为 scrypt 哈希入库,迁移后本行可删除。
|
||||
# 之后请在后台「系统配置」页修改密码。留空 = 不启用密码登录(本地开发默认)。
|
||||
ADMIN_PASSWORD=
|
||||
# 管理接口会话有效期(小时),默认 7 天
|
||||
ADMIN_SESSION_TTL_HOURS=168
|
||||
# 备选: 机器/脚本直接调接口用的密钥(请求头 X-API-Key),与密码二选一即可
|
||||
ADMIN_API_KEY=
|
||||
|
||||
@@ -2,6 +2,9 @@ FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 直连官方源不稳定,固定使用清华 PyPI 镜像
|
||||
ENV PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
|
||||
RUN pip install --no-cache-dir hatchling
|
||||
COPY pyproject.toml README.md ./
|
||||
COPY src ./src
|
||||
|
||||
@@ -70,6 +70,11 @@ def run_migrations_online() -> None:
|
||||
|
||||
with connectable.connect() as connection:
|
||||
_ensure_version_table(connection)
|
||||
# SQLAlchemy 2.0 autobegin:上面的探测 SELECT 会留下隐式事务。
|
||||
# 若不结束,alembic(>=1.16)会判定处于「外部事务」而全程不提交,
|
||||
# 迁移在连接关闭时被静默回滚(upgrade 退出码仍为 0)。
|
||||
if connection.in_transaction():
|
||||
connection.commit()
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
|
||||
with context.begin_transaction():
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""新增 app_settings 表(后台运行时配置)
|
||||
|
||||
Revision ID: 0010_app_settings
|
||||
Revises: 0009_match_stats_xg_fields
|
||||
Create Date: 2026-09-18
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '0010_app_settings'
|
||||
down_revision: Union[str, None] = '0009_match_stats_xg_fields'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'app_settings',
|
||||
sa.Column('key', sa.String(100), primary_key=True),
|
||||
sa.Column('value', sa.Text(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('app_settings')
|
||||
@@ -0,0 +1,27 @@
|
||||
"""predictions 增加备选比分字段
|
||||
|
||||
Revision ID: 0011_prediction_alt_scores
|
||||
Revises: 0010_app_settings
|
||||
Create Date: 2026-09-19
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '0011_prediction_alt_scores'
|
||||
down_revision: Union[str, None] = '0010_app_settings'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('predictions', sa.Column('alt_pred_home_goals', sa.Integer(), nullable=True))
|
||||
op.add_column('predictions', sa.Column('alt_pred_away_goals', sa.Integer(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('predictions', 'alt_pred_away_goals')
|
||||
op.drop_column('predictions', 'alt_pred_home_goals')
|
||||
@@ -10,6 +10,10 @@ server {
|
||||
location /api/ {
|
||||
proxy_pass http://api:8000/api/;
|
||||
proxy_http_version 1.1;
|
||||
# LLM 多专家预测/回测耗时长(可达数分钟),默认 60s 会掐断请求返回 504
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { NavLink, Outlet, useLocation } from 'react-router-dom'
|
||||
import { fetchAuthState, logout, UNAUTHORIZED_EVENT } from './api'
|
||||
import { fetchHealth } from './dal'
|
||||
import Login from './Login'
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ to: '/admin', label: '仪表盘', icon: '◇', end: true },
|
||||
@@ -18,6 +20,7 @@ const NAV_ITEMS = [
|
||||
{ to: '/admin/data-sources', label: '数据源', icon: '◫' },
|
||||
{ to: '/admin/llm-config', label: 'LLM 配置', icon: '◬' },
|
||||
{ to: '/admin/config', label: '系统配置', icon: '◑' },
|
||||
{ to: '/admin/logs', label: '系统日志', icon: '▤' },
|
||||
]
|
||||
|
||||
/** 报眉日期行,与前台同款式 */
|
||||
@@ -33,8 +36,32 @@ function dateLine(): string {
|
||||
export default function AdminLayout() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
const [healthOk, setHealthOk] = useState<boolean | null>(null)
|
||||
const [authed, setAuthed] = useState<boolean | null>(null)
|
||||
const location = useLocation()
|
||||
|
||||
// 登录门禁:挂载时探测会话,收到 401 事件(会话过期)自动切回登录页
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
fetchAuthState()
|
||||
.then(s => alive && setAuthed(s.authenticated))
|
||||
.catch(() => alive && setAuthed(false))
|
||||
const onUnauthorized = () => setAuthed(false)
|
||||
window.addEventListener(UNAUTHORIZED_EVENT, onUnauthorized)
|
||||
return () => {
|
||||
alive = false
|
||||
window.removeEventListener(UNAUTHORIZED_EVENT, onUnauthorized)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleLogout = useCallback(async () => {
|
||||
try {
|
||||
await logout()
|
||||
} catch {
|
||||
/* 会话可能已失效,直接切回登录页 */
|
||||
}
|
||||
setAuthed(false)
|
||||
}, [])
|
||||
|
||||
const checkHealth = useCallback(async () => {
|
||||
try {
|
||||
const h = await fetchHealth()
|
||||
@@ -65,6 +92,18 @@ export default function AdminLayout() {
|
||||
return () => document.removeEventListener('keydown', handler)
|
||||
}, [])
|
||||
|
||||
// 登录门禁:未登录只渲染登录页,不泄露后台任何内容
|
||||
if (authed === null) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-paper-50 text-xs text-ink-400">
|
||||
正在验证登录状态…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!authed) {
|
||||
return <Login onSuccess={() => setAuthed(true)} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-paper-50 text-ink-800">
|
||||
{/* ── 移动端遮罩层 ── */}
|
||||
@@ -177,6 +216,12 @@ export default function AdminLayout() {
|
||||
>
|
||||
前台
|
||||
</a>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="text-ink-500 transition-colors hover:text-press"
|
||||
>
|
||||
登出
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Admin 后台 - 专家与终裁独立 LLM 配置卡片
|
||||
*
|
||||
* 每个角色(5 专家 + 终裁)可独立覆盖 模型 / 接口地址 / API Key;
|
||||
* 留空字段不改动,「恢复继承」删除该角色全部覆盖。
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { fetchLLMAgents, updateSetting, clearSetting } from './dal'
|
||||
import type { LLMAgentConfig } from './types'
|
||||
import { Card, CardBody, CardHeader, Badge, Alert, Spinner, SkeletonBlock } from './components'
|
||||
|
||||
type FieldKey = 'model' | 'base_url' | 'api_key'
|
||||
|
||||
const FIELD_META: { key: FieldKey; label: string; sensitive: boolean; hint: string }[] = [
|
||||
{ key: 'model', label: '模型', sensitive: false, hint: '留空保持现状;未覆盖时继承默认' },
|
||||
{ key: 'base_url', label: '接口地址', sensitive: false, hint: '留空保持现状;未覆盖时继承全局' },
|
||||
{ key: 'api_key', label: 'API Key', sensitive: true, hint: '留空保持现状;未覆盖时继承全局' },
|
||||
]
|
||||
|
||||
const KEY_BY_FIELD: Record<FieldKey, (id: string) => string> = {
|
||||
model: id => `AGENT_${id.toUpperCase()}_MODEL`,
|
||||
base_url: id => `AGENT_${id.toUpperCase()}_BASE_URL`,
|
||||
api_key: id => `AGENT_${id.toUpperCase()}_API_KEY`,
|
||||
}
|
||||
|
||||
export default function AgentLLMCard() {
|
||||
const [agents, setAgents] = useState<LLMAgentConfig[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
const [form, setForm] = useState<Record<FieldKey, string>>({ model: '', base_url: '', api_key: '' })
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [notice, setNotice] = useState<{ ok: boolean; text: string } | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
setAgents(await fetchLLMAgents())
|
||||
} catch {
|
||||
setAgents([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
function toggleExpand(agent: LLMAgentConfig) {
|
||||
if (expandedId === agent.id) {
|
||||
setExpandedId(null)
|
||||
return
|
||||
}
|
||||
setExpandedId(agent.id)
|
||||
setNotice(null)
|
||||
// 预填非敏感覆盖值;API Key 不回填
|
||||
setForm({
|
||||
model: agent.fields.model.origin === 'db' ? agent.fields.model.masked : '',
|
||||
base_url: agent.fields.base_url.origin === 'db' ? agent.fields.base_url.masked : '',
|
||||
api_key: '',
|
||||
})
|
||||
}
|
||||
|
||||
async function handleSave(agent: LLMAgentConfig) {
|
||||
setBusy(true)
|
||||
setNotice(null)
|
||||
try {
|
||||
const nonEmpty = (FIELD_META.filter(f => form[f.key].trim())).map(f => f)
|
||||
if (nonEmpty.length === 0) {
|
||||
setNotice({ ok: false, text: '没有需要保存的修改(全部为空)' })
|
||||
return
|
||||
}
|
||||
for (const f of nonEmpty) {
|
||||
await updateSetting(KEY_BY_FIELD[f.key](agent.id), form[f.key].trim())
|
||||
}
|
||||
setNotice({ ok: true, text: `${agent.label} 配置已保存,立即生效` })
|
||||
setExpandedId(null)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setNotice({ ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '保存失败' })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReset(agent: LLMAgentConfig) {
|
||||
setBusy(true)
|
||||
setNotice(null)
|
||||
try {
|
||||
for (const f of FIELD_META) {
|
||||
await clearSetting(KEY_BY_FIELD[f.key](agent.id))
|
||||
}
|
||||
setNotice({ ok: true, text: `${agent.label} 已恢复继承默认` })
|
||||
setExpandedId(null)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setNotice({ ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '恢复失败' })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const hasOverride = (agent: LLMAgentConfig) =>
|
||||
Object.values(agent.fields).some(f => f.origin === 'db')
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="专家与终裁 LLM 配置"
|
||||
description="可为每个角色单独指定模型、接口地址或 API Key;未覆盖的角色按「专家层/终裁层默认 → 全局」继承"
|
||||
action={
|
||||
<button onClick={load} disabled={loading} className="btn btn-sm">
|
||||
{loading ? (<><Spinner /> 加载中</>) : '刷新'}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<CardBody className="px-0 sm:px-0">
|
||||
{loading ? (
|
||||
<div className="space-y-2 px-4 sm:px-5">
|
||||
{[1, 2, 3, 4, 5, 6].map(i => <SkeletonBlock key={i} className="h-9 w-full" />)}
|
||||
</div>
|
||||
) : agents.length === 0 ? (
|
||||
<p className="py-6 text-center text-xs text-ink-400">无法加载角色配置</p>
|
||||
) : (
|
||||
<>
|
||||
{notice && (
|
||||
<div className="px-4 pb-2 sm:px-5">
|
||||
<Alert kind={notice.ok ? 'ok' : 'error'} title={notice.text} />
|
||||
</div>
|
||||
)}
|
||||
{agents.map(agent => {
|
||||
const expanded = expandedId === agent.id
|
||||
return (
|
||||
<div key={agent.id} className="border-b border-ink-200 last:border-b-0">
|
||||
{/* 行:角色名 + 生效模型 + 配置按钮 */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 px-4 py-2.5 sm:px-5">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="font-serif text-sm font-bold text-ink-900">{agent.label}</span>
|
||||
{hasOverride(agent) && <Badge status="success">独立配置</Badge>}
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-mono text-2xs text-ink-500">{agent.effective_model}</span>
|
||||
<button onClick={() => toggleExpand(agent)} disabled={busy} className="btn btn-sm flex-shrink-0">
|
||||
{expanded ? '收起' : '配置'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 展开的编辑表单 */}
|
||||
{expanded && (
|
||||
<div className="space-y-3 border-t border-ink-200 bg-paper-100/40 px-4 py-3 sm:px-5">
|
||||
{FIELD_META.map(f => {
|
||||
const state = agent.fields[f.key]
|
||||
return (
|
||||
<div key={f.key} className="grid gap-1 sm:grid-cols-[96px_minmax(0,1fr)] sm:items-center sm:gap-3">
|
||||
<label className="text-xs text-ink-500">{f.label}</label>
|
||||
<div>
|
||||
<input
|
||||
type={f.sensitive ? 'password' : 'text'}
|
||||
value={form[f.key]}
|
||||
onChange={e => setForm(prev => ({ ...prev, [f.key]: e.target.value }))}
|
||||
placeholder={
|
||||
f.key === 'api_key' && state.origin === 'db'
|
||||
? `已覆盖(${state.masked}),留空保持不变`
|
||||
: f.hint
|
||||
}
|
||||
autoComplete="off"
|
||||
className="field w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<div className="flex items-center justify-between gap-2 pt-1">
|
||||
<p className="text-2xs text-ink-400">
|
||||
生效模型:{agent.effective_model}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
{hasOverride(agent) && (
|
||||
<button onClick={() => handleReset(agent)} disabled={busy} className="btn btn-sm">
|
||||
恢复继承
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => handleSave(agent)} disabled={busy} className="btn btn-solid btn-sm">
|
||||
{busy ? (<><Spinner /> 保存中</>) : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Admin 后台 - 登录页(报刊风)
|
||||
*
|
||||
* 密码验证通过后由服务端写入 HttpOnly 会话 Cookie。
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ApiError, login } from './api'
|
||||
|
||||
export default function Login({ onSuccess }: { onSuccess: () => void }) {
|
||||
const [password, setPassword] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!password || submitting) return
|
||||
setSubmitting(true)
|
||||
setError('')
|
||||
try {
|
||||
await login(password)
|
||||
onSuccess()
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof ApiError
|
||||
? err.message.split('\n')[0]
|
||||
: '登录失败,请检查网络连接',
|
||||
)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-paper-50 text-ink-800">
|
||||
<header className="masthead-rule">
|
||||
<div className="mx-auto w-full max-w-md px-5 pt-12 sm:pt-16">
|
||||
<div className="border-b border-ink-900 py-5 text-center">
|
||||
<h1 className="font-serif text-3xl font-bold tracking-widest text-ink-900">
|
||||
先知
|
||||
<span className="ml-3 align-baseline font-serif text-sm font-normal italic tracking-normal text-ink-500">
|
||||
Profeto
|
||||
</span>
|
||||
</h1>
|
||||
<p className="mt-2 text-2xs tracking-[0.4em] text-ink-500">管理后台 · 管理员登录</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex flex-1 items-start justify-center px-5 py-10">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="w-full max-w-sm border border-ink-300 bg-white p-6 shadow-[4px_4px_0_0_rgba(0,0,0,0.06)]"
|
||||
>
|
||||
<label htmlFor="admin-password" className="block text-xs font-medium tracking-wide text-ink-700">
|
||||
管理密码
|
||||
</label>
|
||||
<input
|
||||
id="admin-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
placeholder="输入服务器 .env 中的 ADMIN_PASSWORD"
|
||||
autoFocus
|
||||
autoComplete="current-password"
|
||||
className="field mt-2 w-full"
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className="mt-3 border-l-2 border-press bg-press-wash/40 px-3 py-2 text-2xs leading-relaxed text-press-dark">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!password || submitting}
|
||||
className="btn btn-solid mt-5 w-full justify-center"
|
||||
>
|
||||
{submitting ? '验证中…' : '登 录'}
|
||||
</button>
|
||||
|
||||
<p className="mt-4 border-t border-ink-200 pt-3 text-center text-2xs leading-relaxed text-ink-400">
|
||||
密码初始来自服务器 .env,可登录后在「系统配置」页修改;连续输错 5 次将锁定 10 分钟。
|
||||
</p>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<footer className="pb-8 text-center text-2xs text-ink-400">
|
||||
<a href="/" className="transition-colors hover:text-press">
|
||||
← 返回前台版面
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -26,7 +26,7 @@ src/admin/
|
||||
├── Monitoring.tsx # 监控面板(存活 + 数据库就绪,30s 自动巡检)
|
||||
├── DataSources.tsx # 数据源管理(数据源配置与测试)
|
||||
├── LLMConfig.tsx # LLM 配置(模型连接与统计)
|
||||
└── Config.tsx # 系统配置(管理员密钥 + .env 查看与修改指南)
|
||||
└── Config.tsx # 系统配置(登录鉴权说明 + .env 查看与修改指南)
|
||||
```
|
||||
|
||||
## 页面说明
|
||||
@@ -69,8 +69,8 @@ src/admin/
|
||||
- 可用模型列表
|
||||
|
||||
### 8. 系统配置 (`/admin/config`)
|
||||
- **管理员密钥管理**: 保存 X-API-Key 到本机 localStorage,之后所有请求自动附带;
|
||||
后端配置了 ADMIN_API_KEY 时,采集 / 回测 / 结算接口依赖此密钥
|
||||
- **登录与鉴权**: 后台由密码登录保护(服务器 .env 的 ADMIN_PASSWORD),
|
||||
会话以 HttpOnly Cookie 保存;脚本直连接口可使用 ADMIN_API_KEY(X-API-Key 请求头)
|
||||
- 配置列表: 脱敏显示 .env 配置项
|
||||
- 配置修改指南: SSH 修改 .env + 重启服务
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Admin 后台 - 配置项行组件(报刊风)
|
||||
*
|
||||
* 展示态:键名 + 来源徽标(数据库覆盖 / .env 默认 / 未配置) + 脱敏值 + 操作按钮
|
||||
* 编辑态:输入框 + 保存/取消
|
||||
* 由数据源页与 LLM 配置页共用。
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { DataSourceSetting } from './types'
|
||||
import { Badge, Spinner } from './components'
|
||||
|
||||
export const ORIGIN_BADGE: Record<DataSourceSetting['origin'], { text: string; status: 'success' | 'info' | 'error' }> = {
|
||||
db: { text: '数据库覆盖', status: 'success' },
|
||||
env: { text: '.env 默认', status: 'info' },
|
||||
none: { text: '未配置', status: 'error' },
|
||||
}
|
||||
|
||||
export default function SettingRow({
|
||||
setting,
|
||||
editing,
|
||||
busy,
|
||||
onEdit,
|
||||
onCancel,
|
||||
onSave,
|
||||
onClear,
|
||||
detectModels,
|
||||
}: {
|
||||
setting: DataSourceSetting
|
||||
editing: boolean
|
||||
busy: boolean
|
||||
onEdit: () => void
|
||||
onCancel: () => void
|
||||
onSave: (value: string) => void
|
||||
onClear: () => void
|
||||
/** 可选:编辑态提供「检测可用模型」能力(如 LLM_MODEL 行) */
|
||||
detectModels?: () => Promise<string[]>
|
||||
}) {
|
||||
const [value, setValue] = useState('')
|
||||
const origin = ORIGIN_BADGE[setting.origin]
|
||||
|
||||
// 行内模型检测
|
||||
const [detecting, setDetecting] = useState(false)
|
||||
const [detected, setDetected] = useState<string[] | null>(null)
|
||||
const [detectError, setDetectError] = useState('')
|
||||
|
||||
// 进入编辑态时清空上次的检测结果
|
||||
useEffect(() => {
|
||||
if (editing) {
|
||||
setDetected(null)
|
||||
setDetectError('')
|
||||
}
|
||||
}, [editing])
|
||||
|
||||
async function handleDetect() {
|
||||
if (!detectModels || detecting) return
|
||||
setDetecting(true)
|
||||
setDetectError('')
|
||||
try {
|
||||
setDetected(await detectModels())
|
||||
} catch (err) {
|
||||
setDetected(null)
|
||||
setDetectError(err instanceof Error ? err.message : '检测失败')
|
||||
} finally {
|
||||
setDetecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<div className="border-b border-ink-200 py-2.5 last:border-b-0">
|
||||
<div className="mb-1.5 flex flex-wrap items-center gap-1.5 text-2xs text-ink-500">
|
||||
<span className="break-all font-mono text-ink-800">{setting.key}</span>
|
||||
{setting.sensitive && <Badge status="warning">敏感</Badge>}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<input
|
||||
type={setting.sensitive ? 'password' : 'text'}
|
||||
value={value}
|
||||
onChange={e => setValue(e.target.value)}
|
||||
placeholder={`输入新的 ${setting.label}`}
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
className="field flex-1"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => onSave(value)}
|
||||
disabled={!value.trim() || busy}
|
||||
className="btn btn-solid btn-sm"
|
||||
>
|
||||
{busy ? (<><Spinner /> 保存中</>) : '保存'}
|
||||
</button>
|
||||
<button onClick={onCancel} disabled={busy} className="btn btn-sm">
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detectModels && (
|
||||
<div className="mt-2 space-y-2">
|
||||
<button onClick={handleDetect} disabled={detecting} className="btn btn-sm">
|
||||
{detecting ? (<><Spinner /> 检测中</>) : detected ? '重新检测' : '检测可用模型'}
|
||||
</button>
|
||||
|
||||
{detectError && (
|
||||
<p className="border-l-2 border-press bg-press-wash/40 px-3 py-1.5 text-2xs leading-relaxed text-press-dark">
|
||||
{detectError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{detected && detected.length > 0 && (
|
||||
<div className="max-h-48 overflow-y-auto border border-ink-200">
|
||||
{detected.map(id => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setValue(id)}
|
||||
className={`flex w-full items-center justify-between gap-3 border-b border-ink-200 px-3 py-1.5 text-left last:border-b-0 hover:bg-paper-100 ${
|
||||
value === id ? 'bg-press-wash/50' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="min-w-0 break-all font-mono text-2xs text-ink-800">{id}</span>
|
||||
{value === id && <Badge status="success">已选</Badge>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detected && detected.length === 0 && !detectError && (
|
||||
<p className="text-2xs text-ink-400">服务未返回可用模型</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5 border-b border-ink-200 py-2.5 last:border-b-0">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="break-all font-mono text-2xs text-ink-800">{setting.key}</span>
|
||||
<Badge status={origin.status}>{origin.text}</Badge>
|
||||
</div>
|
||||
<div className="break-all font-mono text-2xs leading-relaxed text-ink-500">
|
||||
{setting.configured ? setting.masked : '—'}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={onEdit} disabled={busy} className="btn btn-sm">
|
||||
{setting.configured ? '更换' : '配置'}
|
||||
</button>
|
||||
{setting.origin === 'db' && (
|
||||
<button onClick={onClear} disabled={busy} className="btn btn-sm" title="删除数据库覆盖值,回落 .env">
|
||||
回落 .env
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+48
-33
@@ -1,33 +1,16 @@
|
||||
/**
|
||||
* Admin 后台管理系统 - 统一 API 客户端
|
||||
*
|
||||
* 写入型/高成本接口(采集、回测、结算)受 X-API-Key 保护:
|
||||
* 密钥在「系统配置」页设置,存于本机 localStorage,每次请求自动附带。
|
||||
* 鉴权:通过 POST /api/v1/auth/login 用密码换取 HttpOnly Cookie 会话,
|
||||
* 同源请求自动携带 Cookie,无需手动管理密钥。
|
||||
* 收到 401 时广播 `profeto:unauthorized` 事件,由 AdminLayout 切回登录页。
|
||||
*/
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
const TIMEOUT_MS = 30_000
|
||||
|
||||
const ADMIN_KEY_STORAGE = 'profeto_admin_key'
|
||||
|
||||
/** 读取本机保存的管理员密钥 */
|
||||
export function getAdminKey(): string {
|
||||
try {
|
||||
return localStorage.getItem(ADMIN_KEY_STORAGE) ?? ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存/清除管理员密钥(传空字符串即清除) */
|
||||
export function setAdminKey(key: string): void {
|
||||
try {
|
||||
if (key) localStorage.setItem(ADMIN_KEY_STORAGE, key)
|
||||
else localStorage.removeItem(ADMIN_KEY_STORAGE)
|
||||
} catch {
|
||||
/* 隐私模式等场景下不可用,静默忽略 */
|
||||
}
|
||||
}
|
||||
/** 会话失效事件名,AdminLayout 监听后弹出登录页 */
|
||||
export const UNAUTHORIZED_EVENT = 'profeto:unauthorized'
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
@@ -40,7 +23,10 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
async function request<T>(
|
||||
path: string,
|
||||
options: RequestInit & { timeoutMs?: number } = {},
|
||||
): Promise<T> {
|
||||
// 修复: 正确拼接 API_BASE
|
||||
const url = path.startsWith('http')
|
||||
? path
|
||||
@@ -48,18 +34,17 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
? path // 已经是绝对路径(如 /health)
|
||||
: `${API_BASE}${path}`
|
||||
|
||||
const { timeoutMs = TIMEOUT_MS, ...fetchOptions } = options
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS)
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||
|
||||
try {
|
||||
const adminKey = getAdminKey()
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
...fetchOptions,
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(adminKey ? { 'X-API-Key': adminKey } : {}),
|
||||
...options.headers,
|
||||
...fetchOptions.headers,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -75,7 +60,8 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
? String((detail as { detail: unknown }).detail)
|
||||
: `HTTP ${res.status}: ${res.statusText}`
|
||||
if (res.status === 401) {
|
||||
message += '\n请在「系统配置」页填写管理员密钥后重试。'
|
||||
message += '\n登录已过期,请重新登录。'
|
||||
window.dispatchEvent(new CustomEvent(UNAUTHORIZED_EVENT))
|
||||
}
|
||||
throw new ApiError(message, res.status, detail)
|
||||
}
|
||||
@@ -102,11 +88,40 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>(path),
|
||||
post: <T>(path: string, body?: unknown) =>
|
||||
request<T>(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined }),
|
||||
put: <T>(path: string, body?: unknown) =>
|
||||
request<T>(path, { method: 'PUT', body: body ? JSON.stringify(body) : undefined }),
|
||||
post: <T>(path: string, body?: unknown, opts?: { timeoutMs?: number }) =>
|
||||
request<T>(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined, ...opts }),
|
||||
put: <T>(path: string, body?: unknown, opts?: { timeoutMs?: number }) =>
|
||||
request<T>(path, { method: 'PUT', body: body ? JSON.stringify(body) : undefined, ...opts }),
|
||||
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
||||
}
|
||||
|
||||
// ── 认证 ────────────────────────────────────────────────────────
|
||||
|
||||
/** 密码登录,成功后服务端写入 HttpOnly 会话 Cookie */
|
||||
export function login(password: string): Promise<{ ok: boolean }> {
|
||||
return api.post(`${API_BASE}/auth/login`, { password })
|
||||
}
|
||||
|
||||
/** 退出登录,清除会话 Cookie */
|
||||
export function logout(): Promise<{ ok: boolean }> {
|
||||
return api.post(`${API_BASE}/auth/logout`)
|
||||
}
|
||||
|
||||
/** 探测当前登录状态 */
|
||||
export function fetchAuthState(): Promise<{
|
||||
authenticated: boolean
|
||||
enabled: boolean
|
||||
password_origin?: 'db' | 'env' | 'none'
|
||||
}> {
|
||||
return api.get(`${API_BASE}/auth/me`)
|
||||
}
|
||||
|
||||
/** 修改管理员密码(成功后所有会话失效,需重新登录) */
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
export { API_BASE }
|
||||
|
||||
+74
-25
@@ -15,6 +15,11 @@ import type {
|
||||
Match,
|
||||
Prediction,
|
||||
EvalSummary,
|
||||
DataSourceStatus,
|
||||
DataSourceSetting,
|
||||
DataSourceTestResult,
|
||||
LLMAgentConfig,
|
||||
LogEntry,
|
||||
} from './types'
|
||||
|
||||
// ── 仪表盘 ──────────────────────────────────────────────────────
|
||||
@@ -53,7 +58,7 @@ export async function triggerCollection(req: CollectionRequest): Promise<any> {
|
||||
leagues: req.leagues,
|
||||
date_from: req.date_from,
|
||||
date_to: req.date_to,
|
||||
status: 'finished',
|
||||
status: req.status || undefined, // 空 = 已完赛 + 未开赛都采集
|
||||
},
|
||||
},
|
||||
understat: {
|
||||
@@ -78,10 +83,14 @@ export async function triggerCollection(req: CollectionRequest): Promise<any> {
|
||||
// ── 预测管理 ────────────────────────────────────────────────────
|
||||
|
||||
export async function triggerPrediction(req: { match_id: number; mode?: string }): Promise<any> {
|
||||
return api.post(`${API_BASE}/predict`, {
|
||||
return api.post(
|
||||
`${API_BASE}/predict`,
|
||||
{
|
||||
match_id: req.match_id,
|
||||
mode: req.mode || 'multi',
|
||||
})
|
||||
},
|
||||
{ timeoutMs: 300_000 },
|
||||
)
|
||||
}
|
||||
|
||||
export async function fetchPredictions(limit = 50): Promise<any[]> {
|
||||
@@ -100,7 +109,7 @@ export async function fetchEvalSummary(): Promise<EvalSummary | null> {
|
||||
}
|
||||
|
||||
export async function triggerBacktest(req: BacktestRequest): Promise<BacktestSummary> {
|
||||
return api.post<BacktestSummary>(`${API_BASE}/backtest`, req)
|
||||
return api.post<BacktestSummary>(`${API_BASE}/backtest`, req, { timeoutMs: 300_000 })
|
||||
}
|
||||
|
||||
// ── 辅助数据 ────────────────────────────────────────────────────
|
||||
@@ -153,29 +162,63 @@ export async function fetchHealth(): Promise<any> {
|
||||
// ── 数据源管理 ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 测试数据源连接 — 调用采集 API 验证连通性
|
||||
* 测试数据源连通性 — 后端真实请求上游一次,不触发入库
|
||||
*/
|
||||
export async function testDataSource(source: 'bzzoiro' | 'understat' | 'injuries'): Promise<any> {
|
||||
const sourceMap: Record<string, { path: string; body: any }> = {
|
||||
bzzoiro: { path: `${API_BASE}/ingest/bzzoiro`, body: { leagues: [], date_from: '', date_to: '', status: 'finished' } },
|
||||
understat: { path: `${API_BASE}/ingest/understat`, body: { league: 'EPL', season: new Date().getFullYear() } },
|
||||
injuries: { path: `${API_BASE}/ingest/injuries`, body: { date: new Date().toISOString().slice(0, 10) } },
|
||||
}
|
||||
const cfg = sourceMap[source]
|
||||
if (!cfg) throw new Error(`未知数据源: ${source}`)
|
||||
return api.post(cfg.path, cfg.body)
|
||||
export function testDataSourceConnection(name: string): Promise<DataSourceTestResult> {
|
||||
return api.post<DataSourceTestResult>(`${API_BASE}/admin/datasources/${name}/test`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据源状态 — 后端暂无专用端点,返回模拟状态
|
||||
* 获取数据源状态与配置(脱敏)
|
||||
*/
|
||||
export async function fetchDataSourceStatuses(): Promise<any[]> {
|
||||
// 后端暂无专用配置端点,返回静态信息
|
||||
return [
|
||||
{ name: 'bzzoiro', label: 'Bzzoiro', keyConfigured: true, maskedKey: 'bz***xxx', lastIngestion: null, status: 'configured' },
|
||||
{ name: 'understat', label: 'Understat', keyConfigured: true, maskedKey: '无需 Key', lastIngestion: null, status: 'configured' },
|
||||
{ name: 'injuries', label: 'Injuries', keyConfigured: true, maskedKey: 'inj***xxx', lastIngestion: null, status: 'configured' },
|
||||
]
|
||||
export function fetchDataSourceStatuses(): Promise<DataSourceStatus[]> {
|
||||
return api.get<DataSourceStatus[]>(`${API_BASE}/admin/datasources`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 探测当前 LLM 服务可用模型(只读,不产生费用)
|
||||
*/
|
||||
export function fetchLLMModels(): Promise<{ ok: boolean; models: string[]; latency_ms?: number; detail: string }> {
|
||||
return api.get(`${API_BASE}/admin/llm/models`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 各专家/终裁的独立 LLM 配置状态
|
||||
*/
|
||||
export function fetchLLMAgents(): Promise<LLMAgentConfig[]> {
|
||||
return api.get<LLMAgentConfig[]>(`${API_BASE}/admin/llm/agents`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询系统日志(内存缓冲,最新在前)
|
||||
*/
|
||||
export function fetchLogs(params: { level?: string; keyword?: string; limit?: number } = {}): Promise<{ entries: LogEntry[]; count: number }> {
|
||||
const sp = new URLSearchParams()
|
||||
if (params.level) sp.set('level', params.level)
|
||||
if (params.keyword) sp.set('keyword', params.keyword)
|
||||
if (params.limit) sp.set('limit', String(params.limit))
|
||||
return api.get<{ entries: LogEntry[]; count: number }>(`${API_BASE}/admin/logs?${sp}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 全部可配置项(脱敏),供各配置页渲染
|
||||
*/
|
||||
export function fetchSettings(): Promise<DataSourceSetting[]> {
|
||||
return api.get<DataSourceSetting[]>(`${API_BASE}/admin/settings`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新配置项(写入 app_settings,覆盖 .env,立即生效)
|
||||
*/
|
||||
export function updateSetting(key: string, value: string) {
|
||||
return api.put<{ key: string; masked: string; origin: string }>(`${API_BASE}/admin/settings/${key}`, { value })
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除配置项的 DB 覆盖值,回落 .env
|
||||
*/
|
||||
export function clearSetting(key: string) {
|
||||
return api.delete<{ key: string; masked: string; origin: string }>(`${API_BASE}/admin/settings/${key}`)
|
||||
}
|
||||
|
||||
// ── LLM 配置 ────────────────────────────────────────────────────
|
||||
@@ -184,10 +227,14 @@ export async function fetchDataSourceStatuses(): Promise<any[]> {
|
||||
* 测试 LLM 连接 — 调用预测端点验证
|
||||
*/
|
||||
export async function testLLMConnection(matchId?: number): Promise<any> {
|
||||
return api.post(`${API_BASE}/predict`, {
|
||||
return api.post(
|
||||
`${API_BASE}/predict`,
|
||||
{
|
||||
match_id: matchId || 1,
|
||||
mode: 'single',
|
||||
})
|
||||
},
|
||||
{ timeoutMs: 300_000 },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -231,7 +278,9 @@ export async function fetchSystemConfig(): Promise<any[]> {
|
||||
{ key: 'LLM_MODEL', value_masked: 'gpt-4o', description: 'LLM 模型', is_sensitive: false },
|
||||
{ key: 'LLM_BASE_URL', value_masked: 'https://api.openai.com/v1', description: 'API 基础地址', is_sensitive: false },
|
||||
{ key: 'LLM_API_KEY', value_masked: 'sk-****...****', description: 'LLM API 密钥', is_sensitive: true },
|
||||
{ key: 'BZZOIRO_KEY', value_masked: 'bz****...****', description: 'Bzzoiro 数据源密钥', is_sensitive: true },
|
||||
{ key: 'ADMIN_PASSWORD', value_masked: '••••••(已配置)', description: '管理后台登录密码', is_sensitive: true },
|
||||
{ key: 'ADMIN_API_KEY', value_masked: '未配置时脚本调用不可用', description: '接口鉴权密钥 (X-API-Key)', is_sensitive: true },
|
||||
{ key: 'BZZOIRO_KEY', value_masked: 'bz****...****', description: 'Bzzoiro 数据源密钥(可在「数据源」页在线配置)', is_sensitive: true },
|
||||
{ key: 'DATABASE_URL', value_masked: 'postgresql://****@localhost/profeto', description: '数据库连接', is_sensitive: true },
|
||||
{ key: 'LOG_LEVEL', value_masked: 'INFO', description: '日志级别', is_sensitive: false },
|
||||
]
|
||||
|
||||
@@ -14,12 +14,15 @@ import { useEffect, useState } from 'react'
|
||||
import { triggerBacktest, fetchEvalSummary, fetchLeagues } from '../dal'
|
||||
import type { BacktestRequest, EvalSummary, League } from '../types'
|
||||
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
|
||||
import TeamSideTag from '../../components/TeamSideTag'
|
||||
|
||||
interface BacktestResultRow {
|
||||
match_id: number
|
||||
league_code?: string | null
|
||||
home_team: string
|
||||
away_team: string
|
||||
home_team_zh?: string | null
|
||||
away_team_zh?: string | null
|
||||
match_date?: string | null
|
||||
actual_score: string
|
||||
actual_1x2?: string
|
||||
@@ -279,8 +282,12 @@ export default function BacktestPage() {
|
||||
<span className="w-24 flex-shrink-0 text-2xs tabular-nums text-ink-400">
|
||||
{fmtDate(r.match_date)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-ink-800">
|
||||
{r.home_team} vs {r.away_team}
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1.5 text-sm text-ink-800">
|
||||
<TeamSideTag side="home" />
|
||||
<span className="truncate">{r.home_team_zh || r.home_team}</span>
|
||||
<span className="flex-shrink-0 text-ink-300">vs</span>
|
||||
<TeamSideTag side="away" />
|
||||
<span className="truncate">{r.away_team_zh || r.away_team}</span>
|
||||
</span>
|
||||
<span className="flex-shrink-0 text-xs tabular-nums text-ink-500">
|
||||
实际 <span className="font-serif font-bold text-ink-900">{r.actual_score}</span>
|
||||
|
||||
@@ -47,6 +47,7 @@ export default function CollectionPage() {
|
||||
const [dateFrom, setDateFrom] = useState('')
|
||||
const [dateTo, setDateTo] = useState('')
|
||||
const [season, setSeason] = useState('')
|
||||
const [ingestStatus, setIngestStatus] = useState('') // 空 = 已完赛+未开赛
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [result, setResult] = useState<{ title: string; detail: string } | null>(null)
|
||||
@@ -70,11 +71,15 @@ export default function CollectionPage() {
|
||||
leagues: leagueCode ? [leagueCode] : undefined,
|
||||
league: leagueCode || undefined,
|
||||
season: season || undefined,
|
||||
status: ingestStatus || undefined,
|
||||
date_from: dateFrom || undefined,
|
||||
date_to: dateTo || undefined,
|
||||
}
|
||||
const res = await triggerCollection(body)
|
||||
setResult(summarizeResult(res, source))
|
||||
await triggerCollection(body)
|
||||
setResult({
|
||||
title: '采集任务已启动',
|
||||
detail: '正在后台执行(上游限速时可能需要几分钟)。完成结果与错误请到「系统日志」页查看(支持自动刷新)。',
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : '采集触发失败')
|
||||
} finally {
|
||||
@@ -126,6 +131,22 @@ export default function CollectionPage() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Bzzoiro 专用: 比赛状态 */}
|
||||
{source === 'bzzoiro' && (
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">比赛状态</label>
|
||||
<select
|
||||
value={ingestStatus}
|
||||
onChange={e => setIngestStatus(e.target.value)}
|
||||
className="field w-full"
|
||||
>
|
||||
<option value="">全部(已完赛 + 未开赛)</option>
|
||||
<option value="finished">仅已完赛</option>
|
||||
<option value="scheduled">仅未开赛</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Understat 专用: 赛季 */}
|
||||
{source === 'understat' && (
|
||||
<div>
|
||||
@@ -199,8 +220,8 @@ export default function CollectionPage() {
|
||||
</div>
|
||||
|
||||
<p className="mt-4 border-l-2 border-ink-300 pl-3 text-2xs leading-relaxed text-ink-500">
|
||||
若后端配置了 ADMIN_API_KEY,采集接口需要管理员密钥。
|
||||
遇到 401 请到「系统配置」页填写密钥。
|
||||
采集接口需要管理员登录。
|
||||
遇到 401 表示登录已过期,请重新登录。
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
@@ -2,24 +2,27 @@
|
||||
* Admin 后台 - 系统配置管理页面(报刊风)
|
||||
*
|
||||
* 功能:
|
||||
* - 管理员密钥(X-API-Key):存本机浏览器,自动附带到采集/回测/结算等受保护接口
|
||||
* - 登录与鉴权说明(ADMIN_PASSWORD,HttpOnly 会话 Cookie)
|
||||
* - 显示当前 .env 配置(脱敏;后端暂无配置端点,为静态说明)
|
||||
* - 配置修改指南
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { fetchSystemConfig } from '../dal'
|
||||
import { getAdminKey, setAdminKey } from '../api'
|
||||
import { changePassword, fetchAuthState, UNAUTHORIZED_EVENT } from '../api'
|
||||
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components'
|
||||
|
||||
export default function ConfigPage() {
|
||||
const [config, setConfig] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [passwordOrigin, setPasswordOrigin] = useState<'db' | 'env' | 'none' | null>(null)
|
||||
|
||||
// 管理员密钥
|
||||
const [adminKey, setAdminKeyInput] = useState('')
|
||||
const [keySaved, setKeySaved] = useState(false)
|
||||
const [keyExists, setKeyExists] = useState(false)
|
||||
// 修改密码表单
|
||||
const [currentPwd, setCurrentPwd] = useState('')
|
||||
const [newPwd, setNewPwd] = useState('')
|
||||
const [confirmPwd, setConfirmPwd] = useState('')
|
||||
const [pwdBusy, setPwdBusy] = useState(false)
|
||||
const [pwdNotice, setPwdNotice] = useState<{ ok: boolean; text: string } | null>(null)
|
||||
|
||||
const loadConfig = useCallback(async () => {
|
||||
setLoading(true)
|
||||
@@ -35,75 +38,115 @@ export default function ConfigPage() {
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig()
|
||||
const stored = getAdminKey()
|
||||
setKeyExists(stored !== '')
|
||||
fetchAuthState()
|
||||
.then(s => setPasswordOrigin(s.password_origin ?? null))
|
||||
.catch(() => setPasswordOrigin(null))
|
||||
}, [loadConfig])
|
||||
|
||||
function handleSaveKey(e: React.FormEvent) {
|
||||
async function handleChangePassword(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setAdminKey(adminKey.trim())
|
||||
setKeyExists(adminKey.trim() !== '')
|
||||
setKeySaved(true)
|
||||
setAdminKeyInput('')
|
||||
setTimeout(() => setKeySaved(false), 3000)
|
||||
setPwdNotice(null)
|
||||
if (newPwd !== confirmPwd) {
|
||||
setPwdNotice({ ok: false, text: '两次输入的新密码不一致' })
|
||||
return
|
||||
}
|
||||
setPwdBusy(true)
|
||||
try {
|
||||
const res = await changePassword(currentPwd, newPwd)
|
||||
setPwdNotice({ ok: true, text: res.message })
|
||||
// 密码即会话密钥,修改后所有会话失效:主动切回登录页
|
||||
setTimeout(() => window.dispatchEvent(new CustomEvent(UNAUTHORIZED_EVENT)), 1500)
|
||||
} catch (err) {
|
||||
setPwdNotice({ ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '修改失败' })
|
||||
} finally {
|
||||
setPwdBusy(false)
|
||||
}
|
||||
|
||||
function handleClearKey() {
|
||||
setAdminKey('')
|
||||
setAdminKeyInput('')
|
||||
setKeyExists(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SectionHeader
|
||||
title="系统配置"
|
||||
description="管理员密钥管理与系统参数查看。敏感配置一律通过服务器 .env 文件管理。"
|
||||
description="登录鉴权说明与系统参数查看。敏感配置一律通过服务器 .env 文件管理。"
|
||||
/>
|
||||
|
||||
{/* 管理员密钥 */}
|
||||
{/* 登录与鉴权 */}
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="管理员密钥 (X-API-Key)"
|
||||
description="后端设置 ADMIN_API_KEY 后,采集 / 回测 / 结算等接口需要此密钥"
|
||||
title="登录与鉴权"
|
||||
description="本后台通过密码登录保护,会话以 HttpOnly Cookie 保存,有效期默认 7 天"
|
||||
/>
|
||||
<CardBody>
|
||||
<form onSubmit={handleSaveKey} className="space-y-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<Alert kind="ok" title="已通过密码登录" />
|
||||
<p className="mt-3 text-2xs leading-relaxed text-ink-500">
|
||||
密码初始来自服务器 <code className="font-mono">.env</code> 的{' '}
|
||||
<code className="font-mono">ADMIN_PASSWORD</code>(启动时自动转为哈希),在下方修改后以{' '}
|
||||
<code className="font-mono">scrypt</code> 哈希安全存入数据库并立即生效,明文不再留存。
|
||||
密码即会话签名密钥,修改后所有已登录会话失效,需用新密码重新登录。
|
||||
脚本直连接口可改用 <code className="font-mono">ADMIN_API_KEY</code>(请求头 X-API-Key)。
|
||||
</p>
|
||||
{passwordOrigin && (
|
||||
<p className="mt-2 flex items-center gap-2 text-2xs text-ink-500">
|
||||
当前密码来源:
|
||||
{passwordOrigin === 'db' ? (
|
||||
<Badge status="success">数据库(scrypt 哈希)</Badge>
|
||||
) : passwordOrigin === 'env' ? (
|
||||
<Badge status="info">.env 初始值</Badge>
|
||||
) : (
|
||||
<Badge status="error">未配置</Badge>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 修改密码表单 */}
|
||||
<form onSubmit={handleChangePassword} className="mt-5 space-y-3 border-t border-ink-200 pt-4">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-2xs text-ink-500">当前密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={adminKey}
|
||||
onChange={e => setAdminKeyInput(e.target.value)}
|
||||
placeholder={keyExists ? '••••••••(已保存,输入新值可更换)' : '粘贴 ADMIN_API_KEY'}
|
||||
autoComplete="off"
|
||||
className="field flex-1"
|
||||
value={currentPwd}
|
||||
onChange={e => setCurrentPwd(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
className="field w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-2xs text-ink-500">新密码(至少 8 位)</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPwd}
|
||||
onChange={e => setNewPwd(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
className="field w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-2xs text-ink-500">确认新密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPwd}
|
||||
onChange={e => setConfirmPwd(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
className="field w-full"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" disabled={!adminKey.trim()} className="btn btn-solid">
|
||||
保存
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearKey}
|
||||
disabled={!keyExists}
|
||||
className="btn btn-sm"
|
||||
>
|
||||
清除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{keySaved && <Alert kind="ok" title="密钥已保存,后续请求将自动附带" />}
|
||||
{keyExists && !keySaved && (
|
||||
<p className="text-2xs text-ink-500">
|
||||
当前状态:<Badge status="success">已保存密钥</Badge>
|
||||
<span className="ml-2">密钥仅保存在本机浏览器,不会上传到任何第三方。</span>
|
||||
</p>
|
||||
{pwdNotice && (
|
||||
<Alert kind={pwdNotice.ok ? 'ok' : 'error'} title={pwdNotice.text} />
|
||||
)}
|
||||
<p className="border-l-2 border-ink-300 pl-3 text-2xs leading-relaxed text-ink-500">
|
||||
密钥与服务器 .env 中 ADMIN_API_KEY 一致即可。留空时后端默认不鉴权(本地开发模式)。
|
||||
遇到 401 错误通常就是缺这个密钥。
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-2xs text-ink-400">修改成功后会自动退出登录,请用新密码重新登录。</p>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pwdBusy || !currentPwd || !newPwd || !confirmPwd}
|
||||
className="btn btn-solid btn-sm flex-shrink-0"
|
||||
>
|
||||
{pwdBusy ? (<><Spinner /> 修改中</>) : '修改密码'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
@@ -203,7 +246,8 @@ docker compose logs -f api`}
|
||||
['LLM_API_KEY', 'LLM 服务商的 API 密钥,用于调用大模型'],
|
||||
['LLM_MODEL', '使用的模型名称,如 gpt-4o、claude-3-5-sonnet'],
|
||||
['LLM_BASE_URL', 'API 基础地址,支持兼容 OpenAI 协议的服务商'],
|
||||
['ADMIN_API_KEY', '管理后台写接口的鉴权密钥,配置后需在本页保存到浏览器'],
|
||||
['ADMIN_PASSWORD', '管理后台登录密码,修改后重启 api 容器生效'],
|
||||
['ADMIN_API_KEY', '脚本直连接口的鉴权密钥(请求头 X-API-Key)'],
|
||||
['BZZOIRO_KEY', 'Bzzoiro 数据源 API 密钥'],
|
||||
['DATABASE_URL', 'PostgreSQL 数据库连接字符串'],
|
||||
].map(([key, desc]) => (
|
||||
|
||||
@@ -2,58 +2,113 @@
|
||||
* Admin 后台 - 数据源管理页面(报刊风)
|
||||
*
|
||||
* 功能:
|
||||
* - 显示当前数据源状态 (bzzoiro / understat / injuries)
|
||||
* - 显示 API Key 配置状态(脱敏显示)
|
||||
* - 测试连接按钮(调用采集 API 验证)
|
||||
* - 数据源说明
|
||||
* - 显示各数据源配置状态(脱敏),标明值来源:DB 覆盖 / .env 默认 / 未配置
|
||||
* - 在线修改数据源 API Key(写入 app_settings,覆盖 .env;清除则回落)
|
||||
* - 测试连接按钮(后端真实请求上游一次,不触发入库)
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { fetchDataSourceStatuses, testDataSource } from '../dal'
|
||||
import type { DataSourceStatus } from '../types'
|
||||
import {
|
||||
fetchDataSourceStatuses,
|
||||
updateSetting,
|
||||
clearSetting,
|
||||
testDataSourceConnection,
|
||||
} from '../dal'
|
||||
import type { DataSourceStatus, DataSourceTestResult } from '../types'
|
||||
import SettingRow from '../SettingRow'
|
||||
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components'
|
||||
|
||||
function formatTime(iso: string | null): string {
|
||||
if (!iso) return '暂无记录'
|
||||
try {
|
||||
return new Date(iso).toLocaleString('zh-CN', { hour12: false })
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
|
||||
export default function DataSourcesPage() {
|
||||
const [sources, setSources] = useState<DataSourceStatus[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadError, setLoadError] = useState('')
|
||||
|
||||
const [testingSource, setTestingSource] = useState<string | null>(null)
|
||||
const [testResults, setTestResults] = useState<Record<string, { success: boolean; message: string }>>({})
|
||||
const [testResults, setTestResults] = useState<Record<string, DataSourceTestResult>>({})
|
||||
|
||||
const [editingKey, setEditingKey] = useState<string | null>(null)
|
||||
const [busyKey, setBusyKey] = useState<string | null>(null)
|
||||
const [rowNotice, setRowNotice] = useState<{ key: string; ok: boolean; text: string } | null>(null)
|
||||
|
||||
const loadSources = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setLoadError('')
|
||||
try {
|
||||
const data = await fetchDataSourceStatuses()
|
||||
setSources(data)
|
||||
} catch {
|
||||
setSources(await fetchDataSourceStatuses())
|
||||
} catch (err) {
|
||||
setLoadError(err instanceof Error ? err.message.split('\n')[0] : '加载失败')
|
||||
setSources([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadSources() }, [loadSources])
|
||||
useEffect(() => {
|
||||
loadSources()
|
||||
}, [loadSources])
|
||||
|
||||
async function handleTest(sourceName: string) {
|
||||
setTestingSource(sourceName)
|
||||
setTestResults(prev => ({ ...prev, [sourceName]: { success: false, message: '测试中...' } }))
|
||||
try {
|
||||
await testDataSource(sourceName as 'bzzoiro' | 'understat' | 'injuries')
|
||||
setTestResults(prev => ({ ...prev, [sourceName]: { success: true, message: '连接成功' } }))
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : '连接失败'
|
||||
setTestResults(prev => ({ ...prev, [sourceName]: { success: false, message: msg } }))
|
||||
const result = await testDataSourceConnection(sourceName)
|
||||
setTestResults(prev => ({ ...prev, [sourceName]: result }))
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message.split('\n')[0] : '连接失败'
|
||||
setTestResults(prev => ({ ...prev, [sourceName]: { ok: false, status: null, latency_ms: 0, detail: msg } }))
|
||||
} finally {
|
||||
setTestingSource(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave(key: string, value: string) {
|
||||
setBusyKey(key)
|
||||
setRowNotice(null)
|
||||
try {
|
||||
await updateSetting(key, value)
|
||||
setRowNotice({ key, ok: true, text: '已保存,立即生效' })
|
||||
setEditingKey(null)
|
||||
await loadSources()
|
||||
} catch (err) {
|
||||
setRowNotice({ key, ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '保存失败' })
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClear(key: string) {
|
||||
setBusyKey(key)
|
||||
setRowNotice(null)
|
||||
try {
|
||||
await clearSetting(key)
|
||||
setRowNotice({ key, ok: true, text: '已清除数据库覆盖,回落 .env 默认值' })
|
||||
await loadSources()
|
||||
} catch (err) {
|
||||
setRowNotice({ key, ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '清除失败' })
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SectionHeader
|
||||
title="数据源管理"
|
||||
description="数据采集源的配置状态与连通性测试。"
|
||||
description="数据采集源的 API 配置与连通性测试。修改保存到数据库并立即生效,无需重启;「回落 .env」删除覆盖值。"
|
||||
/>
|
||||
|
||||
{loadError && (
|
||||
<Alert kind="error" title="无法加载数据源配置" message={loadError} />
|
||||
)}
|
||||
|
||||
{/* 数据源卡片 */}
|
||||
{loading ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
@@ -73,39 +128,73 @@ export default function DataSourcesPage() {
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{sources.map(source => {
|
||||
const result = testResults[source.name]
|
||||
const cardKeys = source.settings.map(s => s.key)
|
||||
return (
|
||||
<Card key={source.name}>
|
||||
<CardBody className="space-y-4">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between border-b border-ink-200 pb-3">
|
||||
<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.keyConfigured ? 'success' : 'error'}>
|
||||
{source.keyConfigured ? '已配置' : '未配置'}
|
||||
<Badge status={source.key_configured ? 'success' : 'error'}>
|
||||
{source.key_configured ? '已就绪' : '缺配置'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* API Key 状态 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-ink-400">API Key</span>
|
||||
<span className="font-mono text-ink-600">{source.maskedKey}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-ink-400">最近采集</span>
|
||||
<span className="text-ink-600">{source.lastIngestion || '暂无记录'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-2xs leading-relaxed text-ink-500">{source.description}</p>
|
||||
|
||||
{/* 测试结果 */}
|
||||
{result && (
|
||||
{/* 配置项 */}
|
||||
{source.settings.length > 0 ? (
|
||||
<div>
|
||||
{source.settings.map(setting => (
|
||||
<SettingRow
|
||||
key={setting.key}
|
||||
setting={setting}
|
||||
editing={editingKey === setting.key}
|
||||
busy={busyKey === setting.key}
|
||||
onEdit={() => {
|
||||
setEditingKey(setting.key)
|
||||
setRowNotice(null)
|
||||
}}
|
||||
onCancel={() => setEditingKey(null)}
|
||||
onSave={v => handleSave(setting.key, v)}
|
||||
onClear={() => handleClear(setting.key)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-2xs text-ink-400">无需 API Key</p>
|
||||
)}
|
||||
|
||||
{/* 行级操作提示 */}
|
||||
{rowNotice && cardKeys.includes(rowNotice.key) && (
|
||||
<Alert
|
||||
kind={result.success ? 'ok' : 'error'}
|
||||
title={result.success ? '连接成功' : '连接失败'}
|
||||
message={result.success ? undefined : result.message}
|
||||
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>
|
||||
|
||||
{/* 测试结果(进行中不渲染,避免占位被误读为失败) */}
|
||||
{result && testingSource !== source.name && (
|
||||
<Alert
|
||||
kind={result.ok ? 'ok' : 'error'}
|
||||
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}
|
||||
@@ -120,30 +209,25 @@ export default function DataSourcesPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 数据源说明 */}
|
||||
{/* 配置说明 */}
|
||||
<Card>
|
||||
<CardHeader title="数据源说明" />
|
||||
<CardHeader title="配置说明" />
|
||||
<CardBody>
|
||||
<div className="grid gap-x-6 gap-y-3 sm:grid-cols-3">
|
||||
<div className="border-t border-ink-200 pt-3">
|
||||
<Badge status="info">Bzzoiro</Badge>
|
||||
<p className="mt-2 text-xs leading-relaxed text-ink-600">
|
||||
历史赛程与比分数据,覆盖全球主要联赛。需要 API Key 配置。
|
||||
<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 未设置」。
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-t border-ink-200 pt-3">
|
||||
<Badge status="info">Understat</Badge>
|
||||
<p className="mt-2 text-xs leading-relaxed text-ink-600">
|
||||
xG(预期进球)进阶数据,无需 API Key,通过网页抓取获取。
|
||||
<p className="border-l-2 border-ink-300 pl-3">
|
||||
敏感值只显示末 4 位(不足 8 位全遮),完整值不回传浏览器。
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-t border-ink-200 pt-3">
|
||||
<Badge status="info">Injuries</Badge>
|
||||
<p className="mt-2 text-xs leading-relaxed text-ink-600">
|
||||
球员伤停信息,用于预测时考虑阵容完整性。需要 API Key 配置。
|
||||
<p className="border-l-2 border-ink-300 pl-3">
|
||||
「测试连接」会真实请求上游接口一次:连通且密钥有效 → 成功并显示耗时;
|
||||
HTTP 401/403 → 密钥无效;其他状态码或超时 → 按详情提示排查。
|
||||
测试不写入任何数据。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -9,17 +9,14 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { testLLMConnection, fetchLLMUsageStats } from '../dal'
|
||||
import { testLLMConnection, fetchLLMUsageStats, fetchSettings, fetchLLMModels, updateSetting, clearSetting } from '../dal'
|
||||
import type { LLMUsageStats } from '../types'
|
||||
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components'
|
||||
import SettingRow from '../SettingRow'
|
||||
import AgentLLMCard from '../AgentLLMCard'
|
||||
import type { DataSourceSetting } from '../types'
|
||||
|
||||
// 可用模型列表
|
||||
const AVAILABLE_MODELS = [
|
||||
{ id: 'gpt-4o', label: 'GPT-4o', provider: 'openai', description: '综合能力最强,适合复杂分析' },
|
||||
{ id: 'gpt-4o-mini', label: 'GPT-4o Mini', provider: 'openai', description: '快速经济,适合批量预测' },
|
||||
{ id: 'claude-3-5-sonnet', label: 'Claude 3.5 Sonnet', provider: 'anthropic', description: '长上下文分析能力强' },
|
||||
{ id: 'deepseek-chat', label: 'DeepSeek V3', provider: 'deepseek', description: '高性价比,中文优化' },
|
||||
]
|
||||
const LLM_SETTING_KEYS = ['LLM_API_KEY', 'LLM_BASE_URL', 'LLM_MODEL']
|
||||
|
||||
export default function LLMConfigPage() {
|
||||
const [stats, setStats] = useState<LLMUsageStats | null>(null)
|
||||
@@ -27,14 +24,12 @@ export default function LLMConfigPage() {
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null)
|
||||
|
||||
// 当前配置(后端暂无配置端点,取 .env 约定值展示)
|
||||
const currentConfig = {
|
||||
provider: 'openai',
|
||||
model: 'gpt-4o',
|
||||
base_url: 'https://api.openai.com/v1',
|
||||
api_key_configured: true,
|
||||
api_key_masked: 'sk-****...****abcd',
|
||||
}
|
||||
// LLM 连接配置(运行时配置,DB 覆盖 .env)
|
||||
const [llmSettings, setLlmSettings] = useState<DataSourceSetting[]>([])
|
||||
const [settingsLoading, setSettingsLoading] = useState(true)
|
||||
const [editingKey, setEditingKey] = useState<string | null>(null)
|
||||
const [busyKey, setBusyKey] = useState<string | null>(null)
|
||||
const [rowNotice, setRowNotice] = useState<{ key: string; ok: boolean; text: string } | null>(null)
|
||||
|
||||
const loadStats = useCallback(async () => {
|
||||
setLoading(true)
|
||||
@@ -48,7 +43,58 @@ export default function LLMConfigPage() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadStats() }, [loadStats])
|
||||
const loadSettings = useCallback(async () => {
|
||||
setSettingsLoading(true)
|
||||
try {
|
||||
const all = await fetchSettings()
|
||||
setLlmSettings(all.filter(x => LLM_SETTING_KEYS.includes(x.key)))
|
||||
} catch {
|
||||
setLlmSettings([])
|
||||
} finally {
|
||||
setSettingsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadStats()
|
||||
loadSettings()
|
||||
}, [loadStats, loadSettings])
|
||||
|
||||
/** 供 LLM_MODEL 行内检测:探测当前服务可用模型,失败抛错由行内展示 */
|
||||
const detectLLMModels = useCallback(async (): Promise<string[]> => {
|
||||
const r = await fetchLLMModels()
|
||||
if (!r.ok) throw new Error(r.detail)
|
||||
return r.models
|
||||
}, [])
|
||||
|
||||
async function handleSave(key: string, value: string) {
|
||||
setBusyKey(key)
|
||||
setRowNotice(null)
|
||||
try {
|
||||
await updateSetting(key, value)
|
||||
setRowNotice({ key, ok: true, text: '已保存,立即生效' })
|
||||
setEditingKey(null)
|
||||
await loadSettings()
|
||||
} catch (err) {
|
||||
setRowNotice({ key, ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '保存失败' })
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClear(key: string) {
|
||||
setBusyKey(key)
|
||||
setRowNotice(null)
|
||||
try {
|
||||
await clearSetting(key)
|
||||
setRowNotice({ key, ok: true, text: '已清除数据库覆盖,回落 .env 默认值' })
|
||||
await loadSettings()
|
||||
} catch (err) {
|
||||
setRowNotice({ key, ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '清除失败' })
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTest() {
|
||||
setTesting(true)
|
||||
@@ -72,29 +118,53 @@ export default function LLMConfigPage() {
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* 当前配置 */}
|
||||
{/* LLM 连接配置 */}
|
||||
<Card>
|
||||
<CardHeader title="当前配置" />
|
||||
<CardHeader
|
||||
title="连接配置"
|
||||
description="保存到数据库并立即生效,优先于服务器 .env"
|
||||
action={
|
||||
<button onClick={loadSettings} disabled={settingsLoading} className="btn btn-sm">
|
||||
{settingsLoading ? (<><Spinner /> 加载中</>) : '刷新'}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<CardBody>
|
||||
<div className="space-y-0">
|
||||
{[
|
||||
{ label: '提供商', value: currentConfig.provider, mono: false },
|
||||
{ label: '模型', value: currentConfig.model, mono: true },
|
||||
{ label: 'API 地址', value: currentConfig.base_url, mono: true },
|
||||
{ label: 'API Key', value: currentConfig.api_key_masked, mono: true },
|
||||
{ label: '模式', value: '多专家 (5 路 + 终裁)', mono: false },
|
||||
].map(row => (
|
||||
<div
|
||||
key={row.label}
|
||||
className="flex items-center justify-between gap-4 border-b border-ink-200 py-2.5 last:border-b-0"
|
||||
>
|
||||
<span className="text-xs text-ink-400">{row.label}</span>
|
||||
<span className={`text-xs text-ink-800 ${row.mono ? 'break-all font-mono' : ''}`}>
|
||||
{row.value}
|
||||
</span>
|
||||
{settingsLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map(i => <SkeletonBlock key={i} className="h-9 w-full" />)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{llmSettings.map(setting => (
|
||||
<SettingRow
|
||||
key={setting.key}
|
||||
setting={setting}
|
||||
editing={editingKey === setting.key}
|
||||
busy={busyKey === setting.key}
|
||||
onEdit={() => {
|
||||
setEditingKey(setting.key)
|
||||
setRowNotice(null)
|
||||
}}
|
||||
onCancel={() => setEditingKey(null)}
|
||||
onSave={v => handleSave(setting.key, v)}
|
||||
onClear={() => handleClear(setting.key)}
|
||||
detectModels={setting.key === 'LLM_MODEL' ? detectLLMModels : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rowNotice && (
|
||||
<div className="mt-3">
|
||||
<Alert kind={rowNotice.ok ? 'ok' : 'error'} title={rowNotice.text} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-3 border-l-2 border-ink-300 pl-3 text-2xs leading-relaxed text-ink-500">
|
||||
模式: 多专家 (5 路 + 终裁)。填入可连通的 OpenAI 兼容服务(如 DeepSeek、
|
||||
智谱、通义或任意网关)后点下方「测试 LLM 连接」验证。
|
||||
</p>
|
||||
|
||||
{/* 测试连接 */}
|
||||
{testResult && (
|
||||
@@ -161,35 +231,8 @@ export default function LLMConfigPage() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 可用模型 */}
|
||||
<Card>
|
||||
<CardHeader title="可用模型" description="在 .env 中修改 LLM_MODEL 后重启服务生效" />
|
||||
<CardBody className="px-0 sm:px-0">
|
||||
{AVAILABLE_MODELS.map(model => {
|
||||
const isCurrent = model.id === currentConfig.model
|
||||
return (
|
||||
<div
|
||||
key={model.id}
|
||||
className={`flex flex-col gap-2 border-b border-ink-200 px-4 py-3 last:border-b-0 sm:flex-row sm:items-center sm:justify-between sm:px-5 ${
|
||||
isCurrent ? 'bg-press-wash/50' : ''
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-ink-900">{model.label}</span>
|
||||
{isCurrent && <Badge status="success">当前</Badge>}
|
||||
</div>
|
||||
<p className="mt-0.5 text-2xs text-ink-500">{model.description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono text-2xs text-ink-400">{model.provider}</span>
|
||||
{!isCurrent && <span className="text-2xs text-ink-400">编辑 .env 切换</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CardBody>
|
||||
</Card>
|
||||
{/* 专家与终裁独立配置 */}
|
||||
<AgentLLMCard />
|
||||
|
||||
{/* 最近预测 */}
|
||||
<Card>
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Admin 后台 - 系统日志页面(报刊风)
|
||||
*
|
||||
* 查看应用运行日志(内存缓冲,最新在前):
|
||||
* - 级别筛选 + 关键字搜索
|
||||
* - 自动刷新(10s)可开关
|
||||
* - 缓冲上限 2000 条,进程重启后清零
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||
import { fetchLogs } from '../dal'
|
||||
import type { LogEntry } from '../types'
|
||||
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components'
|
||||
|
||||
const LEVELS = ['', 'INFO', 'WARNING', 'ERROR'] as const
|
||||
|
||||
const LEVEL_BADGE: Record<string, { status: 'success' | 'info' | 'warning' | 'error'; text: string }> = {
|
||||
DEBUG: { status: 'info', text: 'DEBUG' },
|
||||
INFO: { status: 'info', text: 'INFO' },
|
||||
WARNING: { status: 'warning', text: 'WARN' },
|
||||
ERROR: { status: 'error', text: 'ERROR' },
|
||||
CRITICAL: { status: 'error', text: 'FATAL' },
|
||||
}
|
||||
|
||||
function fmtTs(ts: number): string {
|
||||
return new Date(ts * 1000).toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
export default function LogsPage() {
|
||||
const [entries, setEntries] = useState<LogEntry[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [level, setLevel] = useState<string>('')
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [autoRefresh, setAutoRefresh] = useState(true)
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const d = await fetchLogs({ level: level || undefined, keyword: keyword || undefined, limit: 300 })
|
||||
setEntries(d.entries)
|
||||
setError('')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message.split('\n')[0] : '加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [level, keyword])
|
||||
|
||||
// 筛选条件变化 → 立即拉取
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
// 自动刷新
|
||||
useEffect(() => {
|
||||
if (timerRef.current) clearInterval(timerRef.current)
|
||||
if (autoRefresh) {
|
||||
timerRef.current = setInterval(load, 10_000)
|
||||
}
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current)
|
||||
}
|
||||
}, [autoRefresh, load])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SectionHeader
|
||||
title="系统日志"
|
||||
description="应用运行日志(登录、配置变更、采集、预测、异常等)。内存缓冲最近 2000 条,重启后清零。"
|
||||
/>
|
||||
|
||||
{error && <Alert kind="error" title="无法加载日志" message={error} />}
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="日志查看"
|
||||
description={entries.length > 0 ? `显示最新 ${entries.length} 条` : undefined}
|
||||
action={
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex cursor-pointer items-center gap-1.5 text-2xs text-ink-500">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoRefresh}
|
||||
onChange={e => setAutoRefresh(e.target.checked)}
|
||||
className="accent-current"
|
||||
/>
|
||||
10s 自动刷新
|
||||
</label>
|
||||
<button onClick={load} disabled={loading} className="btn btn-sm">
|
||||
{loading ? (<><Spinner /> 加载中</>) : '刷新'}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<CardBody className="px-0 sm:px-0">
|
||||
{/* 筛选栏 */}
|
||||
<div className="flex flex-col gap-2 border-b border-ink-200 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-5">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{LEVELS.map(lv => (
|
||||
<button
|
||||
key={lv || 'all'}
|
||||
onClick={() => setLevel(lv)}
|
||||
className={`btn btn-sm ${level === lv ? 'btn-solid' : ''}`}
|
||||
>
|
||||
{lv || '全部'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
value={keyword}
|
||||
onChange={e => setKeyword(e.target.value)}
|
||||
placeholder="搜索关键字(消息 / logger)…"
|
||||
className="field w-full sm:w-64"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 日志列表 */}
|
||||
{loading && entries.length === 0 ? (
|
||||
<div className="space-y-2 px-4 py-3 sm:px-5">
|
||||
{[1, 2, 3, 4, 5].map(i => <SkeletonBlock key={i} className="h-8 w-full" />)}
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
<p className="py-10 text-center text-xs text-ink-400">暂无匹配的日志</p>
|
||||
) : (
|
||||
<div>
|
||||
{entries.map((e, i) => {
|
||||
const badge = LEVEL_BADGE[e.level] ?? { status: 'info' as const, text: e.level }
|
||||
return (
|
||||
<div
|
||||
key={`${e.ts}-${i}`}
|
||||
className="flex flex-col gap-0.5 border-b border-ink-200 px-4 py-2 last:border-b-0 sm:flex-row sm:items-baseline sm:gap-3 sm:px-5"
|
||||
>
|
||||
<span className="w-40 flex-shrink-0 text-2xs tabular-nums text-ink-400">{fmtTs(e.ts)}</span>
|
||||
<span className="w-14 flex-shrink-0">
|
||||
<Badge status={badge.status}>{badge.text}</Badge>
|
||||
</span>
|
||||
<span className="w-40 flex-shrink-0 truncate font-mono text-2xs text-ink-400" title={e.logger}>
|
||||
{e.logger}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 break-all font-mono text-2xs leading-relaxed text-ink-800">
|
||||
{e.message}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -66,7 +66,7 @@ export default function MonitoringPage() {
|
||||
<Alert
|
||||
kind="error"
|
||||
title="无法连接到后端"
|
||||
message={`${error}\n请确认服务是否正常运行,以及管理员密钥是否需要配置。`}
|
||||
message={`${error}\n请确认服务是否正常运行,以及登录会话是否已过期。`}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -13,13 +13,14 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { triggerPrediction, fetchPredictions, fetchMatches, settlePrediction } from '../dal'
|
||||
import type { Match, Prediction } from '../types'
|
||||
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
|
||||
import { teamSidePrefix } from '../../components/TeamSideTag'
|
||||
|
||||
const AGENT_LABELS: Record<string, string> = {
|
||||
h2h: '历史交锋',
|
||||
form: '近期状态',
|
||||
stats: '攻防数据',
|
||||
home_away: '主客因素',
|
||||
injuries: '阵容完整性',
|
||||
h2h: '历史交锋分析专家',
|
||||
form: '近期状态分析专家',
|
||||
stats: '攻防数据分析专家',
|
||||
home_away: '主客因素分析专家',
|
||||
injuries: '阵容完整性分析专家',
|
||||
}
|
||||
|
||||
const OUTCOME_LABEL: Record<string, string> = { '1': '主胜', X: '平局', '2': '客胜' }
|
||||
@@ -64,7 +65,7 @@ export default function PredictionsPage() {
|
||||
for (const m of matches) {
|
||||
const home = m.home_team_zh || m.home_team
|
||||
const away = m.away_team_zh || m.away_team
|
||||
map.set(m.id, `${home} vs ${away}`)
|
||||
map.set(m.id, `${teamSidePrefix('home')}${home} vs ${teamSidePrefix('away')}${away}`)
|
||||
}
|
||||
return map
|
||||
}, [matches])
|
||||
@@ -140,7 +141,7 @@ export default function PredictionsPage() {
|
||||
<option value="">选择比赛</option>
|
||||
{matches.map(m => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{(m.home_team_zh || m.home_team)} vs {(m.away_team_zh || m.away_team)}
|
||||
{teamSidePrefix('home')}{(m.home_team_zh || m.home_team)} vs {teamSidePrefix('away')}{(m.away_team_zh || m.away_team)}
|
||||
({m.match_date?.slice(5, 10)})
|
||||
</option>
|
||||
))}
|
||||
|
||||
@@ -15,6 +15,7 @@ import MonitoringPage from './pages/Monitoring'
|
||||
import DataSourcesPage from './pages/DataSources'
|
||||
import LLMConfigPage from './pages/LLMConfig'
|
||||
import ConfigPage from './pages/Config'
|
||||
import LogsPage from './pages/Logs'
|
||||
|
||||
export const adminRoutes = [
|
||||
{
|
||||
@@ -29,6 +30,7 @@ export const adminRoutes = [
|
||||
{ path: 'data-sources', element: <DataSourcesPage /> },
|
||||
{ path: 'llm-config', element: <LLMConfigPage /> },
|
||||
{ path: 'config', element: <ConfigPage /> },
|
||||
{ path: 'logs', element: <LogsPage /> },
|
||||
{ path: '*', element: <Navigate to="/admin" replace /> },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -90,6 +90,7 @@ export interface PredictRequest {
|
||||
// ── 数据采集 ────────────────────────────────────────────────────
|
||||
|
||||
export interface CollectionRequest {
|
||||
status?: string
|
||||
source: 'bzzoiro' | 'understat' | 'injuries'
|
||||
leagues?: string[]
|
||||
league?: string
|
||||
@@ -138,13 +139,30 @@ export interface BacktestSummary {
|
||||
|
||||
// ── 数据源配置 ──────────────────────────────────────────────────
|
||||
|
||||
export interface DataSourceSetting {
|
||||
key: string
|
||||
label: string
|
||||
description: string
|
||||
sensitive: boolean
|
||||
configured: boolean
|
||||
masked: string
|
||||
origin: 'db' | 'env' | 'none'
|
||||
}
|
||||
|
||||
export interface DataSourceStatus {
|
||||
name: string
|
||||
label: string
|
||||
keyConfigured: boolean
|
||||
maskedKey: string
|
||||
lastIngestion: string | null
|
||||
status: 'configured' | 'missing_key' | 'untested'
|
||||
description: string
|
||||
key_configured: boolean
|
||||
last_ingestion: string | null
|
||||
settings: DataSourceSetting[]
|
||||
}
|
||||
|
||||
export interface DataSourceTestResult {
|
||||
ok: boolean
|
||||
status: number | null
|
||||
latency_ms: number
|
||||
detail: string
|
||||
}
|
||||
|
||||
export interface DataSourceTestRequest {
|
||||
@@ -193,3 +211,31 @@ export interface SystemConfigEntry {
|
||||
description: string
|
||||
is_sensitive: boolean
|
||||
}
|
||||
|
||||
// ── 专家/终裁独立 LLM 配置 ────────────────────────────────────────
|
||||
|
||||
export interface LLMAgentFieldState {
|
||||
configured: boolean
|
||||
masked: string
|
||||
origin: 'db' | 'env' | 'none'
|
||||
}
|
||||
|
||||
export interface LLMAgentConfig {
|
||||
id: string
|
||||
label: string
|
||||
effective_model: string
|
||||
fields: {
|
||||
model: LLMAgentFieldState
|
||||
base_url: LLMAgentFieldState
|
||||
api_key: LLMAgentFieldState
|
||||
}
|
||||
}
|
||||
|
||||
// ── 系统日志 ─────────────────────────────────────────────────────
|
||||
|
||||
export interface LogEntry {
|
||||
ts: number
|
||||
level: string
|
||||
logger: string
|
||||
message: string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* 主客队标志:报刊风小方框字。
|
||||
*
|
||||
* 主队 = 反白实心块,客队 = 细线框,与整版纸色语言一致。
|
||||
* 用法: <TeamSideTag side="home" /> 队名
|
||||
*/
|
||||
|
||||
export default function TeamSideTag({ side }: { side: 'home' | 'away' }) {
|
||||
return (
|
||||
<span
|
||||
aria-label={side === 'home' ? '主队' : '客队'}
|
||||
className={`inline-block flex-shrink-0 border px-1 text-center font-sans text-2xs leading-4 ${
|
||||
side === 'home'
|
||||
? 'border-ink-900 bg-ink-900 text-paper-50'
|
||||
: 'border-ink-300 text-ink-400'
|
||||
}`}
|
||||
>
|
||||
{side === 'home' ? '主' : '客'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** 纯文本场景(<option>、字符串拼接)的主客队前缀 */
|
||||
export function teamSidePrefix(side: 'home' | 'away'): string {
|
||||
return side === 'home' ? '[主] ' : '[客] '
|
||||
}
|
||||
+225
-44
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import TeamSideTag from '../components/TeamSideTag'
|
||||
|
||||
interface Match {
|
||||
id: number
|
||||
@@ -25,6 +26,8 @@ interface Prediction {
|
||||
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
|
||||
@@ -50,11 +53,11 @@ interface AgentReport {
|
||||
}
|
||||
|
||||
const AGENT_LABELS: Record<string, string> = {
|
||||
h2h: '历史交锋',
|
||||
form: '近期状态',
|
||||
stats: '攻防数据',
|
||||
home_away: '主客因素',
|
||||
injuries: '阵容完整性',
|
||||
h2h: '历史交锋分析专家',
|
||||
form: '近期状态分析专家',
|
||||
stats: '攻防数据分析专家',
|
||||
home_away: '主客因素分析专家',
|
||||
injuries: '阵容完整性分析专家',
|
||||
}
|
||||
|
||||
const LEAGUES = [
|
||||
@@ -193,6 +196,17 @@ export default function Matches() {
|
||||
// 把旧结果覆盖到新筛选上。用递增序号只认最后一次请求的响应。
|
||||
const loadSeq = useRef(0)
|
||||
const predictSeq = useRef(0)
|
||||
// 预测请求控制器:关闭弹窗时中止
|
||||
const predictAbort = useRef<AbortController | null>(null)
|
||||
|
||||
function closePredict() {
|
||||
predictAbort.current?.abort()
|
||||
predictSeq.current++ // 令中止请求的 catch/then 全部失效,不再写入错误
|
||||
setPredictingId(null)
|
||||
setPrediction(null)
|
||||
setPredictionFor(null)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const seq = ++loadSeq.current
|
||||
@@ -247,11 +261,16 @@ export default function Matches() {
|
||||
setError(null)
|
||||
setPrediction(null)
|
||||
setPredictionFor(m)
|
||||
// LLM 多专家预测耗时可达数分钟,给足超时(与 nginx 代理 300s 对齐)
|
||||
const controller = new AbortController()
|
||||
predictAbort.current = controller
|
||||
const timer = setTimeout(() => controller.abort(), 300_000)
|
||||
try {
|
||||
const res = await fetch('/api/v1/predict', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ match_id: m.id, mode }),
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (seq !== predictSeq.current) return
|
||||
if (!res.ok) {
|
||||
@@ -263,8 +282,15 @@ export default function Matches() {
|
||||
setPrediction(data)
|
||||
} catch (e) {
|
||||
if (seq !== predictSeq.current) return
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(
|
||||
e instanceof DOMException && e.name === 'AbortError'
|
||||
? '预测超时(5 分钟),请稍后重试或改用单次模式'
|
||||
: e instanceof Error
|
||||
? e.message
|
||||
: String(e),
|
||||
)
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
if (seq === predictSeq.current) setPredictingId(null)
|
||||
}
|
||||
}
|
||||
@@ -363,6 +389,18 @@ export default function Matches() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 预测弹窗:进行中可视化 / 结果面板 ── */}
|
||||
{predictionFor && (
|
||||
<PredictModal
|
||||
match={predictionFor}
|
||||
mode={mode}
|
||||
predicting={predictingId === predictionFor.id}
|
||||
prediction={predictingId === predictionFor.id ? null : prediction}
|
||||
error={predictingId === predictionFor.id ? null : error}
|
||||
onClose={closePredict}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── 赛程栏:表格化,行间细线 ── */}
|
||||
<section aria-label="赛程">
|
||||
{loading && <SkeletonRows n={4} />}
|
||||
@@ -380,6 +418,7 @@ export default function Matches() {
|
||||
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'
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -398,7 +437,8 @@ export default function Matches() {
|
||||
{/* 对阵:移动端主队/比分/客队同一行,桌面端 sm:contents 拆回 grid 列 */}
|
||||
<div className="flex items-center gap-2 sm:contents">
|
||||
{/* 主队(右对齐) */}
|
||||
<div className="flex min-w-0 flex-1 items-center justify-end">
|
||||
<div className="flex min-w-0 flex-1 items-center justify-end gap-1.5">
|
||||
<TeamSideTag side="home" />
|
||||
<span className="truncate text-sm font-medium text-ink-900">{homeName}</span>
|
||||
</div>
|
||||
|
||||
@@ -419,7 +459,8 @@ export default function Matches() {
|
||||
</div>
|
||||
|
||||
{/* 客队(左对齐) */}
|
||||
<div className="flex min-w-0 flex-1 items-center">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<TeamSideTag side="away" />
|
||||
<span className="truncate text-sm font-medium text-ink-900">{awayName}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -429,6 +470,7 @@ export default function Matches() {
|
||||
|
||||
{/* 预测按钮 */}
|
||||
<div className="flex justify-end">
|
||||
{!finished && (
|
||||
<button
|
||||
onClick={() => predict(m)}
|
||||
disabled={busy}
|
||||
@@ -437,6 +479,7 @@ export default function Matches() {
|
||||
>
|
||||
{busy ? (<><Spinner /> 预测中</>) : '预测'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -452,32 +495,164 @@ export default function Matches() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── 预测中占位 ── */}
|
||||
{predictingId && !prediction && (
|
||||
<div className="border border-ink-900">
|
||||
<div className="flex items-center gap-2 border-b border-ink-900 bg-paper-100 px-4 py-2.5">
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/** 预测过程阶段(按时长模拟;结果到达即跳到完成) */
|
||||
function PredictProgress({ mode }: { mode: 'single' | 'multi' }) {
|
||||
const [elapsed, setElapsed] = useState(0)
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => setElapsed(e => e + 0.5), 500)
|
||||
return () => clearInterval(t)
|
||||
}, [])
|
||||
|
||||
// 阶段阈值(秒): 切片 → 专家(各路依次点亮) → 终裁
|
||||
const SLICE_END = mode === 'multi' ? 3 : 3
|
||||
const AGENT_START = 4
|
||||
const AGENT_STEP = 8 // 每路专家约 8s 点亮一路
|
||||
const AGG_START = mode === 'multi' ? AGENT_START + AGENT_STEP * 5 : SLICE_END + 1
|
||||
const agents = ['form', 'stats', 'home_away', 'injuries', 'h2h']
|
||||
|
||||
const phase = elapsed < SLICE_END ? 'slice'
|
||||
: mode === 'single'
|
||||
? 'model'
|
||||
: elapsed < AGG_START ? 'agents' : 'agg'
|
||||
|
||||
const pct = Math.min(95, Math.round((elapsed / (mode === 'multi' ? 70 : 20)) * 100))
|
||||
|
||||
return (
|
||||
<div className="px-5 py-8 sm:px-8">
|
||||
{/* 阶段标题 */}
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Spinner className="text-press" />
|
||||
<span className="text-sm font-medium text-ink-800">正在生成预测</span>
|
||||
<span className="text-2xs text-ink-500">
|
||||
{mode === 'multi' ? '五路专家并行分析后终裁,约需 20-60 秒' : '单次调用,约需 5-15 秒'}
|
||||
<span className="font-serif text-sm font-bold text-ink-900">
|
||||
{phase === 'slice' && '正在组装比赛数据切片'}
|
||||
{phase === 'agents' && '五路专家并行分析中'}
|
||||
{phase === 'model' && '模型分析中'}
|
||||
{phase === 'agg' && '终裁专家汇总裁定中'}
|
||||
</span>
|
||||
<span className="text-2xs tabular-nums text-ink-400">{elapsed.toFixed(0)}s</span>
|
||||
</div>
|
||||
<div className="space-y-4 px-4 py-6">
|
||||
<div className="flex items-center justify-center gap-6">
|
||||
<div className="skeleton h-4 w-20" />
|
||||
<div className="skeleton h-10 w-24" />
|
||||
<div className="skeleton h-4 w-20" />
|
||||
</div>
|
||||
<div className="skeleton mx-auto h-px w-64" />
|
||||
<div className="skeleton h-16 w-full" />
|
||||
</div>
|
||||
|
||||
{/* 进度条:渐进式,不封顶到 100% */}
|
||||
<div className="mx-auto mt-5 h-1 w-full max-w-md overflow-hidden bg-ink-100" role="progressbar" aria-valuenow={pct}>
|
||||
<div
|
||||
className={`h-full bg-press transition-all duration-500 ${phase === 'agg' || phase === 'model' ? 'animate-pulse' : ''}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 专家灯序(多专家模式) */}
|
||||
{mode === 'multi' && (
|
||||
<ul className="mx-auto mt-6 max-w-md space-y-1.5">
|
||||
{agents.map((a, i) => {
|
||||
const lit = elapsed >= AGENT_START + AGENT_STEP * (i + 1)
|
||||
const activeNow = !lit && elapsed >= AGENT_START + AGENT_STEP * i
|
||||
return (
|
||||
<li
|
||||
key={a}
|
||||
className={`flex items-center justify-between border-b border-ink-200 pb-1.5 text-xs transition-colors ${
|
||||
lit ? 'text-ink-800' : activeNow ? 'text-ink-900' : 'text-ink-300'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`inline-block h-1.5 w-1.5 ${lit ? 'bg-ink-900' : activeNow ? 'bg-press animate-pulse' : 'bg-ink-200'}`}
|
||||
/>
|
||||
{AGENT_LABELS[a] ?? a}
|
||||
</span>
|
||||
{lit && <span className="text-2xs text-ink-400">✓ 完成</span>}
|
||||
{activeNow && <span className="text-2xs text-press">分析中…</span>}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* ── 预测版 ── */}
|
||||
{prediction && predictionFor && (
|
||||
<PredictionPanel prediction={prediction} match={predictionFor} mode={mode} />
|
||||
)}
|
||||
<p className="mt-6 text-center text-2xs text-ink-400">
|
||||
{mode === 'multi' ? '五路专家并行分析后终裁,约需 30-90 秒;关闭窗口即取消' : '单次调用,约需 5-20 秒;关闭窗口即取消'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 预测弹窗:进行中显示过程可视化,完成后显示预测版,失败显示原因 */
|
||||
function PredictModal({
|
||||
match,
|
||||
mode,
|
||||
predicting,
|
||||
prediction,
|
||||
error,
|
||||
onClose,
|
||||
}: {
|
||||
match: Match
|
||||
mode: 'single' | 'multi'
|
||||
predicting: boolean
|
||||
prediction: Prediction | null
|
||||
error: string | null
|
||||
onClose: () => void
|
||||
}) {
|
||||
const homeName = match.home_team_zh || match.home_team
|
||||
const awayName = match.away_team_zh || match.away_team
|
||||
|
||||
useEffect(() => {
|
||||
const h = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('keydown', h)
|
||||
return () => document.removeEventListener('keydown', h)
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-ink-900/50 p-4 sm:items-center"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`预测 ${homeName} 对 ${awayName}`}
|
||||
onClick={e => {
|
||||
if (e.target === e.currentTarget) onClose()
|
||||
}}
|
||||
>
|
||||
<div className="relative w-full max-w-2xl bg-paper-50 shadow-2xl">
|
||||
{/* 弹窗报头 */}
|
||||
<div className="flex items-center justify-between 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">
|
||||
预测版 ·
|
||||
<TeamSideTag side="home" />
|
||||
{homeName}
|
||||
<span>对</span>
|
||||
<TeamSideTag side="away" />
|
||||
{awayName}
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex h-7 w-7 items-center justify-center text-ink-400 transition-colors hover:text-ink-900"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path d="M6.3 5.3a1 1 0 011.4 0L10 7.6l2.3-2.3a1 1 0 111.4 1.4L11.4 9l2.3 2.3a1 1 0 01-1.4 1.4L10 10.4l-2.3 2.3a1 1 0 01-1.4-1.4L8.6 9 6.3 6.7a1 1 0 010-1.4z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 弹窗体 */}
|
||||
{predicting ? (
|
||||
<PredictProgress mode={mode} />
|
||||
) : error ? (
|
||||
<div className="px-5 py-10 text-center sm:px-8">
|
||||
<p className="font-serif text-sm font-bold text-press">预测失败</p>
|
||||
<p className="mx-auto mt-3 max-w-md whitespace-pre-wrap text-left text-xs leading-relaxed text-ink-600">
|
||||
{error}
|
||||
</p>
|
||||
<button onClick={onClose} className="btn btn-sm mt-6">关闭</button>
|
||||
</div>
|
||||
) : prediction ? (
|
||||
<PredictionPanel prediction={prediction} match={match} mode={mode} embedded />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -486,27 +661,37 @@ function PredictionPanel({
|
||||
prediction,
|
||||
match,
|
||||
mode,
|
||||
embedded = false,
|
||||
}: {
|
||||
prediction: Prediction
|
||||
match: Match
|
||||
mode: 'single' | 'multi'
|
||||
/** 弹窗嵌入模式:弹窗已提供报头,这里省略自带版头 */
|
||||
embedded?: boolean
|
||||
}) {
|
||||
const homeName = match.home_team_zh || match.home_team
|
||||
const awayName = match.away_team_zh || match.away_team
|
||||
const okReports = (prediction.agent_outputs ?? []).filter(r => r.status === 'ok')
|
||||
|
||||
return (
|
||||
<article className="border border-ink-900 bg-paper-50">
|
||||
{/* 版头 */}
|
||||
<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="font-serif text-sm font-bold text-ink-900">
|
||||
预测版 · {homeName} 对 {awayName}
|
||||
<h3 className="flex flex-wrap items-center gap-1.5 font-serif text-sm font-bold text-ink-900">
|
||||
预测版 ·
|
||||
<TeamSideTag side="home" />
|
||||
{homeName}
|
||||
<span>对</span>
|
||||
<TeamSideTag side="away" />
|
||||
{awayName}
|
||||
</h3>
|
||||
<span className="text-2xs tabular-nums text-ink-500">
|
||||
{prediction.provider} / {prediction.model}
|
||||
{prediction.latency_ms !== null && ` · ${(prediction.latency_ms / 1000).toFixed(1)}s`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-7 px-4 py-6 sm:px-5">
|
||||
{/* ── 预测比分:版面核心,大号宋体 ── */}
|
||||
@@ -517,6 +702,14 @@ 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 && (
|
||||
<p className="mt-2 text-2xs tabular-nums text-ink-400">
|
||||
备选{' '}
|
||||
<span className="font-serif text-sm font-bold tabular-nums text-ink-600">
|
||||
{prediction.alt_pred_home_goals}<span className="mx-0.5 font-normal text-ink-300">:</span>{prediction.alt_pred_away_goals}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── 胜平负 ── */}
|
||||
@@ -566,18 +759,6 @@ function PredictionPanel({
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── 原始上下文 ── */}
|
||||
<details className="group">
|
||||
<summary className="flex cursor-pointer list-none items-center gap-1.5 text-xs text-ink-500 transition-colors hover:text-ink-800">
|
||||
<svg viewBox="0 0 20 20" className="h-3 w-3 transition-transform group-open:rotate-90" fill="currentColor" aria-hidden="true">
|
||||
<path d="M7.3 5.3a1 1 0 011.4 0l4 4a1 1 0 010 1.4l-4 4a1 1 0 01-1.4-1.4L10.6 10 7.3 6.7a1 1 0 010-1.4z" />
|
||||
</svg>
|
||||
查看喂给模型的完整数据切片
|
||||
</summary>
|
||||
<pre className="mt-2 max-h-80 overflow-auto border border-ink-200 bg-paper-100 p-3 font-mono text-2xs leading-relaxed text-ink-600">
|
||||
{prediction.context}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
|
||||
@@ -12,6 +12,8 @@ dependencies = [
|
||||
"asyncpg>=0.29",
|
||||
"psycopg2-binary>=2.9",
|
||||
"httpx>=0.27",
|
||||
"alembic>=1.13",
|
||||
"cryptography>=42.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -19,6 +21,8 @@ dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-asyncio>=0.23",
|
||||
"httpx>=0.27",
|
||||
"alembic>=1.13",
|
||||
"cryptography>=42.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
|
||||
@@ -15,17 +15,29 @@ from src.core.config import settings
|
||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
from src.db.base import init_db
|
||||
from src.core.http_client import close_client
|
||||
from src.core.runtime_config import (
|
||||
ensure_admin_password_hashed,
|
||||
migrate_plaintext_sensitive_settings,
|
||||
)
|
||||
await init_db() # 验证连接,不建表
|
||||
await migrate_plaintext_sensitive_settings() # 明文敏感配置 → 加密(幂等)
|
||||
await ensure_admin_password_hashed() # .env 明文密码 → scrypt 哈希(幂等)
|
||||
yield
|
||||
await close_client()
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
from src.core.log_buffer import setup_memory_logging
|
||||
setup_memory_logging(settings.LOG_LEVEL)
|
||||
|
||||
# 生产环境不暴露 OpenAPI 文档(避免向访客泄露接口结构)
|
||||
openapi_url = "/openapi.json" if settings.APP_ENV != "production" else None
|
||||
app = FastAPI(
|
||||
title="Profeto API",
|
||||
description="足球数据 + LLM 预测服务",
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
openapi_url=openapi_url,
|
||||
)
|
||||
|
||||
origins = [o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip()]
|
||||
@@ -44,12 +56,16 @@ def create_app() -> FastAPI:
|
||||
from src.api.routes.ingest import router as ingest_router
|
||||
from src.api.routes.eval import router as eval_router
|
||||
from src.api.routes.backtest import router as backtest_router
|
||||
from src.api.routes.auth import router as auth_router
|
||||
from src.api.routes.admin_settings import router as admin_settings_router
|
||||
|
||||
app.include_router(matches_router)
|
||||
app.include_router(predict_router)
|
||||
app.include_router(ingest_router)
|
||||
app.include_router(eval_router)
|
||||
app.include_router(backtest_router)
|
||||
app.include_router(auth_router)
|
||||
app.include_router(admin_settings_router)
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
|
||||
+77
-16
@@ -1,37 +1,98 @@
|
||||
"""API 依赖:鉴权等横切关注点。
|
||||
|
||||
审查报告 P2-7:ingest / backtest / settle 这类「写入型或高成本」接口此前
|
||||
完全无鉴权 —— 任何能访问到服务的人都可触发采集、或直接烧掉 LLM 额度。
|
||||
|
||||
策略(渐进式,不破坏本地开发):
|
||||
- `ADMIN_API_KEY` 未配置 → 直接放行,并打一次 warning。
|
||||
这样本地 `docker compose up` 无需额外配置即可用。
|
||||
- 已配置 → 必须带匹配的 `X-API-Key` 请求头,否则 401。
|
||||
- 管理员密码:库中 scrypt 哈希优先,回落 .env 初始值;后台可在线修改。
|
||||
- 密码已配置(哈希或 .env)→ 管理后台可用密码登录,登录后颁发 HttpOnly
|
||||
Cookie 会话;受保护接口接受 Cookie 会话或 X-API-Key。
|
||||
- 仅配置 `ADMIN_API_KEY` → 受保护接口只接受 `X-API-Key` 请求头(机器/脚本调用)。
|
||||
- 两者都未配置 → 直接放行,并打一次 warning(本地开发模式)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from fastapi import Header, HTTPException
|
||||
from fastapi import Header, HTTPException, Request
|
||||
|
||||
from src.core.config import settings
|
||||
from src.core.runtime_config import get_admin_password_hash
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SESSION_COOKIE = "profeto_session"
|
||||
|
||||
async def require_admin_key(x_api_key: str | None = Header(None, alias="X-API-Key")) -> None:
|
||||
"""保护「写入型 / 高成本」接口的依赖。
|
||||
|
||||
用法: `@router.post("/ingest/bzzoiro", dependencies=[Depends(require_admin_key)])`
|
||||
def _sign(exp_ts: int, secret: bytes) -> str:
|
||||
msg = f"profeto-admin:{exp_ts}".encode()
|
||||
return hmac.new(secret, msg, "sha256").hexdigest()
|
||||
|
||||
|
||||
async def get_session_secret() -> bytes:
|
||||
"""会话签名密钥 = HMAC(SECRET_KEY, 管理员凭证指纹)。
|
||||
|
||||
指纹来自密码哈希(密码本身永不参与签名):密码变更 → 指纹变化
|
||||
→ 全部旧会话失效,无需额外吊销机制。
|
||||
"""
|
||||
expected = settings.ADMIN_API_KEY
|
||||
if not expected:
|
||||
from src.core.runtime_config import get_admin_credential_fingerprint
|
||||
|
||||
fingerprint = await get_admin_credential_fingerprint()
|
||||
key = settings.SECRET_KEY or f"fallback:{settings.DATABASE_URL}"
|
||||
return hmac.new(key.encode(), b"session:" + fingerprint.encode(), "sha256").digest()
|
||||
|
||||
|
||||
def create_session_token(secret: bytes) -> str:
|
||||
exp_ts = int(time.time()) + settings.ADMIN_SESSION_TTL_HOURS * 3600
|
||||
return f"{exp_ts}.{_sign(exp_ts, secret)}"
|
||||
|
||||
|
||||
def verify_session_token(token: str, secret: bytes) -> bool:
|
||||
try:
|
||||
exp_raw, sig = token.split(".", 1)
|
||||
exp_ts = int(exp_raw)
|
||||
if exp_ts < int(time.time()):
|
||||
return False
|
||||
return secrets.compare_digest(sig, _sign(exp_ts, secret))
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
async def auth_configured() -> bool:
|
||||
"""是否已启用鉴权(密码哈希/.env 密码/API Key 任一)。"""
|
||||
return bool(
|
||||
await get_admin_password_hash()
|
||||
or settings.ADMIN_PASSWORD
|
||||
or settings.ADMIN_API_KEY
|
||||
)
|
||||
|
||||
|
||||
async def require_admin(
|
||||
request: Request,
|
||||
x_api_key: str | None = Header(None, alias="X-API-Key"),
|
||||
) -> None:
|
||||
"""统一保护管理接口:接受 Cookie 会话(密码登录)或 X-API-Key。
|
||||
|
||||
用法: `@router.get("/leagues", dependencies=[Depends(require_admin)])`
|
||||
"""
|
||||
if not await auth_configured():
|
||||
logger.warning(
|
||||
"ADMIN_API_KEY 未设置,采集/回测接口当前【无鉴权】。"
|
||||
"生产环境请设置该环境变量。"
|
||||
"管理员密码 / ADMIN_API_KEY 均未设置,管理接口当前【无鉴权】。"
|
||||
"生产环境请至少设置其中一项。"
|
||||
)
|
||||
return
|
||||
|
||||
if not x_api_key or not secrets.compare_digest(x_api_key, expected):
|
||||
raise HTTPException(status_code=401, detail="无效或缺失的 X-API-Key")
|
||||
# 1) Cookie 会话(密码登录颁发)
|
||||
token = request.cookies.get(SESSION_COOKIE)
|
||||
if token and verify_session_token(token, await get_session_secret()):
|
||||
return
|
||||
|
||||
# 2) X-API-Key(机器/脚本调用;key 明文只在内存中,与第三方交互必需)
|
||||
if (
|
||||
settings.ADMIN_API_KEY
|
||||
and x_api_key
|
||||
and secrets.compare_digest(x_api_key, settings.ADMIN_API_KEY)
|
||||
):
|
||||
return
|
||||
|
||||
raise HTTPException(status_code=401, detail="未登录或凭证无效")
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
"""后台管理路由:数据源配置的查看、修改与连通性测试。
|
||||
|
||||
所有接口需管理员鉴权(require_admin)。配置项白名单见
|
||||
src/core/runtime_config.py SETTING_DEFS,之外的 key 一律拒绝。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select
|
||||
import httpx
|
||||
|
||||
from src.api.deps import require_admin
|
||||
from src.core.config import settings
|
||||
from src.core.http_client import get_client
|
||||
from src.core.log_buffer import get_entries
|
||||
from src.core.runtime_config import (
|
||||
AGENT_META,
|
||||
SETTING_DEFS,
|
||||
clear_runtime_value,
|
||||
get_runtime_value,
|
||||
get_setting_origin,
|
||||
mask_value,
|
||||
set_runtime_value,
|
||||
)
|
||||
from src.db.base import AsyncSession, get_db_read
|
||||
from src.db.models import Injury, MatchStats
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/admin", tags=["admin"], dependencies=[Depends(require_admin)])
|
||||
|
||||
# ── 数据源元数据 ────────────────────────────────────────────────
|
||||
|
||||
_SOURCES: list[dict] = [
|
||||
{
|
||||
"name": "bzzoiro",
|
||||
"label": "Bzzoiro",
|
||||
"description": "历史赛程与比分数据,覆盖全球主要联赛",
|
||||
"setting_keys": ["BZZOIRO_KEY", "BZZOIRO_BASE"],
|
||||
},
|
||||
{
|
||||
"name": "understat",
|
||||
"label": "Understat",
|
||||
"description": "xG(预期进球)进阶数据,无需 API Key,网页抓取",
|
||||
"setting_keys": [],
|
||||
},
|
||||
{
|
||||
"name": "injuries",
|
||||
"label": "Injuries (API-Football)",
|
||||
"description": "球员伤停信息,用于预测时考虑阵容完整性",
|
||||
"setting_keys": ["API_FOOTBALL_KEY"],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class SettingUpdateIn(BaseModel):
|
||||
value: str
|
||||
|
||||
|
||||
async def _last_ingestion(db: AsyncSession, source: str) -> datetime | None:
|
||||
"""各源最近一次采集时间(取自数据血缘字段,无记录返回 None)。"""
|
||||
if source == "injuries":
|
||||
return (await db.execute(select(func.max(Injury.retrieved_at)))).scalar()
|
||||
return (
|
||||
await db.execute(
|
||||
select(func.max(MatchStats.retrieved_at)).where(MatchStats.source == source)
|
||||
)
|
||||
).scalar()
|
||||
|
||||
|
||||
@router.get("/datasources")
|
||||
async def list_datasources(db: AsyncSession = Depends(get_db_read)):
|
||||
"""数据源列表:各配置项的脱敏值、来源(db/env/none)与最近采集时间。"""
|
||||
result = []
|
||||
for src in _SOURCES:
|
||||
settings_out = []
|
||||
for key in src["setting_keys"]:
|
||||
origin, value = await get_setting_origin(key)
|
||||
defn = SETTING_DEFS[key]
|
||||
settings_out.append(
|
||||
{
|
||||
"key": key,
|
||||
"label": defn.label,
|
||||
"description": defn.description,
|
||||
"sensitive": defn.sensitive,
|
||||
"configured": origin != "none",
|
||||
"masked": mask_value(value, defn.sensitive),
|
||||
"origin": origin,
|
||||
}
|
||||
)
|
||||
key_configured = all(s["configured"] for s in settings_out) if settings_out else True
|
||||
last = await _last_ingestion(db, src["name"])
|
||||
result.append(
|
||||
{
|
||||
"name": src["name"],
|
||||
"label": src["label"],
|
||||
"description": src["description"],
|
||||
"key_configured": key_configured,
|
||||
"last_ingestion": last.isoformat() if last else None,
|
||||
"settings": settings_out,
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/settings")
|
||||
async def list_settings():
|
||||
"""全部可配置项(脱敏),供后台各配置页渲染。"""
|
||||
out = []
|
||||
for key, defn in SETTING_DEFS.items():
|
||||
origin, value = await get_setting_origin(key)
|
||||
out.append(
|
||||
{
|
||||
"key": key,
|
||||
"label": defn.label,
|
||||
"description": defn.description,
|
||||
"sensitive": defn.sensitive,
|
||||
"configured": origin != "none",
|
||||
"masked": mask_value(value, defn.sensitive),
|
||||
"origin": origin,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
# ── LLM 可用模型检测 ────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/logs")
|
||||
async def read_logs(
|
||||
level: str | None = Query(None, description="最低级别: DEBUG/INFO/WARNING/ERROR"),
|
||||
keyword: str | None = Query(None, description="消息或 logger 关键字"),
|
||||
limit: int = Query(200, ge=1, le=1000),
|
||||
):
|
||||
"""查询应用运行日志(内存环形缓冲,最新在前;进程重启后清零)。"""
|
||||
entries = get_entries(level, keyword, limit)
|
||||
return {"entries": entries, "count": len(entries)}
|
||||
|
||||
|
||||
@router.get("/llm/agents")
|
||||
async def list_llm_agents():
|
||||
"""各专家/终裁的独立 LLM 配置状态(含当前生效模型的解析结果)。"""
|
||||
out = []
|
||||
for agent in AGENT_META:
|
||||
aid = agent["id"].upper()
|
||||
pfx = f"AGENT_{aid}_"
|
||||
fields = {}
|
||||
for suffix in ("MODEL", "BASE_URL", "API_KEY"):
|
||||
origin, value = await get_setting_origin(f"{pfx}{suffix}")
|
||||
defn = SETTING_DEFS[f"{pfx}{suffix}"]
|
||||
fields[suffix.lower()] = {
|
||||
"configured": origin != "none",
|
||||
"masked": mask_value(value, defn.sensitive),
|
||||
"origin": origin,
|
||||
}
|
||||
# 生效模型 = 覆盖 → 层级默认(专家/终裁 env) → 全局 LLM_MODEL
|
||||
tier_default = (
|
||||
settings.LLM_AGGREGATOR_MODEL if agent["id"] == "aggregator" else settings.LLM_SPECIALIST_MODEL
|
||||
)
|
||||
effective_model = (
|
||||
fields["model"]["masked"]
|
||||
if fields["model"]["configured"]
|
||||
else (tier_default or await get_runtime_value("LLM_MODEL"))
|
||||
)
|
||||
out.append(
|
||||
{
|
||||
"id": agent["id"],
|
||||
"label": agent["label"],
|
||||
"fields": fields,
|
||||
"effective_model": effective_model,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/llm/models")
|
||||
async def list_llm_models():
|
||||
"""探测当前 LLM 服务可用的模型列表(OpenAI 兼容 GET /models)。
|
||||
|
||||
只读探测,不产生费用;配置缺失或服务不可达时返回 ok=false 与原因。
|
||||
"""
|
||||
base_url = (await get_runtime_value("LLM_BASE_URL")).rstrip("/")
|
||||
api_key = await get_runtime_value("LLM_API_KEY")
|
||||
if not base_url or not api_key:
|
||||
return {"ok": False, "models": [], "detail": "LLM_BASE_URL 或 LLM_API_KEY 未配置"}
|
||||
|
||||
client = get_client()
|
||||
start = time.monotonic()
|
||||
try:
|
||||
resp = await client.get(
|
||||
f"{base_url}/models",
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
timeout=httpx.Timeout(connect=10.0, read=20.0, write=10.0, pool=10.0),
|
||||
)
|
||||
except Exception as e:
|
||||
return {
|
||||
"ok": False,
|
||||
"models": [],
|
||||
"latency_ms": int((time.monotonic() - start) * 1000),
|
||||
"detail": f"无法连接 LLM 服务: {e}",
|
||||
}
|
||||
|
||||
latency = int((time.monotonic() - start) * 1000)
|
||||
if resp.status_code in (401, 403):
|
||||
return {"ok": False, "models": [], "latency_ms": latency, "detail": "密钥无效或无权限(HTTP 401/403)"}
|
||||
if resp.status_code != 200:
|
||||
return {"ok": False, "models": [], "latency_ms": latency, "detail": f"服务返回 HTTP {resp.status_code}"}
|
||||
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
return {"ok": False, "models": [], "latency_ms": latency, "detail": "响应不是合法 JSON"}
|
||||
|
||||
models: list[str] = []
|
||||
items = data.get("data") if isinstance(data, dict) else None
|
||||
if isinstance(items, list):
|
||||
models = sorted(
|
||||
str(m.get("id")) for m in items if isinstance(m, dict) and m.get("id")
|
||||
)
|
||||
if not models:
|
||||
return {"ok": False, "models": [], "latency_ms": latency, "detail": "服务未返回模型列表"}
|
||||
return {"ok": True, "models": models, "latency_ms": latency, "detail": f"共 {len(models)} 个可用模型"}
|
||||
|
||||
|
||||
@router.put("/settings/{key}")
|
||||
async def update_setting(key: str, body: SettingUpdateIn):
|
||||
"""更新配置项(写入 app_settings 覆盖 .env)。传空值请改用 DELETE。"""
|
||||
if key not in SETTING_DEFS:
|
||||
raise HTTPException(404, f"不支持的配置项: {key}")
|
||||
value = body.value.strip()
|
||||
if not value:
|
||||
raise HTTPException(400, "值不能为空;如需回落 .env 请调用清除接口")
|
||||
await set_runtime_value(key, value)
|
||||
defn = SETTING_DEFS[key]
|
||||
return {"key": key, "masked": mask_value(value, defn.sensitive), "origin": "db"}
|
||||
|
||||
|
||||
@router.delete("/settings/{key}")
|
||||
async def clear_setting(key: str):
|
||||
"""清除 DB 覆盖值,回落 .env 默认。"""
|
||||
if key not in SETTING_DEFS:
|
||||
raise HTTPException(404, f"不支持的配置项: {key}")
|
||||
await clear_runtime_value(key)
|
||||
origin, value = await get_setting_origin(key)
|
||||
defn = SETTING_DEFS[key]
|
||||
return {
|
||||
"key": key,
|
||||
"masked": mask_value(value, defn.sensitive),
|
||||
"origin": origin,
|
||||
}
|
||||
|
||||
|
||||
# ── 连通性测试 ──────────────────────────────────────────────────
|
||||
|
||||
_TEST_TIMEOUT = 15
|
||||
|
||||
|
||||
async def _probe(url: str, headers: dict | None = None, params: dict | None = None) -> dict:
|
||||
"""单次 HTTP 探测,返回 (ok, status, latency_ms, detail)。不重试。"""
|
||||
client = get_client()
|
||||
start = time.monotonic()
|
||||
try:
|
||||
resp = await client.get(url, headers=headers, params=params, timeout=_TEST_TIMEOUT)
|
||||
except Exception as e:
|
||||
return {
|
||||
"ok": False,
|
||||
"status": None,
|
||||
"latency_ms": int((time.monotonic() - start) * 1000),
|
||||
"detail": f"无法连接: {e}",
|
||||
}
|
||||
latency = int((time.monotonic() - start) * 1000)
|
||||
status = resp.status_code
|
||||
if status == 200:
|
||||
detail = "连接成功"
|
||||
elif status in (401, 403):
|
||||
detail = "服务可达,但密钥无效或无权限"
|
||||
else:
|
||||
detail = f"服务返回 HTTP {status}"
|
||||
return {"ok": status == 200, "status": status, "latency_ms": latency, "detail": detail}
|
||||
|
||||
|
||||
@router.post("/datasources/{name}/test")
|
||||
async def test_datasource(name: str):
|
||||
"""轻量连通性测试:真实请求上游一次,不触发任何入库。"""
|
||||
src = next((s for s in _SOURCES if s["name"] == name), None)
|
||||
if src is None:
|
||||
raise HTTPException(404, f"未知数据源: {name}")
|
||||
|
||||
if name == "bzzoiro":
|
||||
key = await get_runtime_value("BZZOIRO_KEY")
|
||||
if not key:
|
||||
return {"ok": False, "status": None, "latency_ms": 0, "detail": "BZZOIRO_KEY 未配置"}
|
||||
base = (await get_runtime_value("BZZOIRO_BASE")).rstrip("/")
|
||||
today = date.today().isoformat()
|
||||
return await _probe(
|
||||
f"{base}/events/",
|
||||
headers={"Authorization": f"Token {key}", "Accept": "application/json"},
|
||||
params={"date_from": today, "date_to": today},
|
||||
)
|
||||
|
||||
if name == "understat":
|
||||
return await _probe(
|
||||
"https://understat.com/league/EPL/2025",
|
||||
headers={"User-Agent": "Mozilla/5.0", "Accept": "text/html"},
|
||||
)
|
||||
|
||||
# injuries (api-football)
|
||||
api_key = await get_runtime_value("API_FOOTBALL_KEY")
|
||||
if not api_key:
|
||||
return {"ok": False, "status": None, "latency_ms": 0, "detail": "API_FOOTBALL_KEY 未配置"}
|
||||
return await _probe(
|
||||
"https://v3.football.api-sports.io/status",
|
||||
headers={"x-apisports-key": api_key},
|
||||
)
|
||||
@@ -0,0 +1,136 @@
|
||||
"""管理后台认证路由:密码登录 → HttpOnly Cookie 会话;支持在线修改密码。
|
||||
|
||||
管理员密码以 scrypt 哈希存于数据库(.env 明文仅作初始值,启动时自动迁移为哈希)。
|
||||
修改密码会改变会话签名密钥,所有已登录会话随之失效,需重新登录。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from pydantic import BaseModel
|
||||
|
||||
from src.api.deps import (
|
||||
SESSION_COOKIE,
|
||||
auth_configured,
|
||||
create_session_token,
|
||||
get_session_secret,
|
||||
require_admin,
|
||||
verify_session_token,
|
||||
)
|
||||
from src.core import crypto
|
||||
from src.core.config import settings
|
||||
from src.core.runtime_config import (
|
||||
get_admin_password_hash,
|
||||
get_setting_origin,
|
||||
set_admin_password_hash,
|
||||
verify_admin_password,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||
|
||||
# 简易防爆破:10 分钟窗口内同一 IP 连续失败 5 次即锁定 10 分钟(内存态,重启清零)
|
||||
_MAX_FAILS = 5
|
||||
_WINDOW_SECONDS = 600
|
||||
_fail_times: dict[str, deque[float]] = defaultdict(deque)
|
||||
|
||||
# 新密码强度要求
|
||||
_MIN_PASSWORD_LEN = 8
|
||||
_MAX_PASSWORD_LEN = 128
|
||||
|
||||
|
||||
class LoginIn(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
class PasswordChangeIn(BaseModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
def _is_locked(ip: str) -> bool:
|
||||
dq = _fail_times.get(ip)
|
||||
if not dq:
|
||||
return False
|
||||
now = time.time()
|
||||
while dq and now - dq[0] > _WINDOW_SECONDS:
|
||||
dq.popleft()
|
||||
return len(dq) >= _MAX_FAILS
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(body: LoginIn, request: Request, response: Response):
|
||||
ip = _client_ip(request)
|
||||
if not (await get_admin_password_hash() or settings.ADMIN_PASSWORD):
|
||||
raise HTTPException(status_code=503, detail="服务器未配置管理员密码,登录不可用")
|
||||
if _is_locked(ip):
|
||||
logger.warning("管理员登录尝试过于频繁 (ip=%s)", ip)
|
||||
raise HTTPException(status_code=429, detail="失败次数过多,请 10 分钟后再试")
|
||||
if not await verify_admin_password(body.password):
|
||||
_fail_times[ip].append(time.time())
|
||||
logger.warning("管理员登录失败 (ip=%s)", ip)
|
||||
raise HTTPException(status_code=401, detail="密码错误")
|
||||
|
||||
_fail_times.pop(ip, None)
|
||||
response.set_cookie(
|
||||
key=SESSION_COOKIE,
|
||||
value=create_session_token(await get_session_secret()),
|
||||
max_age=settings.ADMIN_SESSION_TTL_HOURS * 3600,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
path="/",
|
||||
)
|
||||
logger.info("管理员登录成功 (ip=%s)", ip)
|
||||
return {"ok": True, "expires_in_hours": settings.ADMIN_SESSION_TTL_HOURS}
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(response: Response):
|
||||
response.delete_cookie(key=SESSION_COOKIE, path="/")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def me(request: Request):
|
||||
"""前端登录门禁探测。未启用鉴权时视为已登录(本地开发模式)。"""
|
||||
token = request.cookies.get(SESSION_COOKIE)
|
||||
authenticated = not await auth_configured() or bool(
|
||||
token and verify_session_token(token, await get_session_secret())
|
||||
)
|
||||
has_hash = bool(await get_admin_password_hash())
|
||||
return {
|
||||
"authenticated": authenticated,
|
||||
"enabled": await auth_configured(),
|
||||
"password_origin": "db" if has_hash else ("env" if settings.ADMIN_PASSWORD else "none"),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/change-password", dependencies=[Depends(require_admin)])
|
||||
async def change_password(body: PasswordChangeIn, request: Request, response: Response):
|
||||
"""修改管理员密码:验证当前密码 → 写运行时覆盖 → 清除会话(全端登出)。"""
|
||||
if not await auth_configured():
|
||||
raise HTTPException(status_code=503, detail="服务器未配置管理员密码,无法修改")
|
||||
if not await verify_admin_password(body.current_password):
|
||||
logger.warning("修改密码失败:当前密码错误 (ip=%s)", _client_ip(request))
|
||||
raise HTTPException(status_code=401, detail="当前密码错误")
|
||||
|
||||
new = body.new_password
|
||||
if not (_MIN_PASSWORD_LEN <= len(new) <= _MAX_PASSWORD_LEN):
|
||||
raise HTTPException(status_code=400, detail=f"新密码长度需在 {_MIN_PASSWORD_LEN}-{_MAX_PASSWORD_LEN} 位之间")
|
||||
if await verify_admin_password(new):
|
||||
raise HTTPException(status_code=400, detail="新密码不能与当前密码相同")
|
||||
|
||||
await set_admin_password_hash(crypto.hash_password(new))
|
||||
# 密码即会话签名密钥,修改后所有旧会话失效;主动清除当前 Cookie 要求重新登录
|
||||
response.delete_cookie(key=SESSION_COOKIE, path="/")
|
||||
logger.info("管理员密码已修改 (ip=%s),所有会话已失效", _client_ip(request))
|
||||
return {"ok": True, "message": "密码已修改,请用新密码重新登录"}
|
||||
@@ -6,7 +6,7 @@ import logging
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from src.api.deps import require_admin_key
|
||||
from src.api.deps import require_admin
|
||||
from src.llm.backtest import run_backtest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -23,7 +23,7 @@ class BacktestRequest(BaseModel):
|
||||
model: str | None = Field(None, description="指定模型 (空=默认)")
|
||||
|
||||
|
||||
@router.post("/backtest", dependencies=[Depends(require_admin_key)])
|
||||
@router.post("/backtest", dependencies=[Depends(require_admin)])
|
||||
async def backtest(req: BacktestRequest):
|
||||
"""对历史比赛运行回测。
|
||||
|
||||
@@ -64,6 +64,8 @@ async def backtest(req: BacktestRequest):
|
||||
"league_code": r.league_code,
|
||||
"home_team": r.home_team,
|
||||
"away_team": r.away_team,
|
||||
"home_team_zh": r.home_team_zh,
|
||||
"away_team_zh": r.away_team_zh,
|
||||
"match_date": r.match_date,
|
||||
"actual_score": f"{r.actual_home}-{r.actual_away}",
|
||||
"actual_1x2": r.actual_1x2,
|
||||
|
||||
@@ -5,7 +5,7 @@ import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from src.api.deps import require_admin_key
|
||||
from src.api.deps import require_admin
|
||||
from src.api.schemas import EvalSummaryOut, SettleRequest
|
||||
from src.db.base import AsyncSession, get_db, get_db_read
|
||||
from src.llm.eval import get_eval_summary, settle_prediction
|
||||
@@ -15,7 +15,7 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/v1", tags=["eval"])
|
||||
|
||||
|
||||
@router.post("/eval/settle", dependencies=[Depends(require_admin_key)])
|
||||
@router.post("/eval/settle", dependencies=[Depends(require_admin)])
|
||||
async def settle(req: SettleRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""回填实际结果。"""
|
||||
try:
|
||||
@@ -29,7 +29,7 @@ async def settle(req: SettleRequest, db: AsyncSession = Depends(get_db)):
|
||||
raise HTTPException(500, "回填失败,请查看服务器日志")
|
||||
|
||||
|
||||
@router.get("/eval/summary", response_model=EvalSummaryOut)
|
||||
@router.get("/eval/summary", response_model=EvalSummaryOut, dependencies=[Depends(require_admin)])
|
||||
async def eval_summary():
|
||||
"""提供商/模型准确率对比。"""
|
||||
return await get_eval_summary()
|
||||
|
||||
+89
-25
@@ -1,12 +1,14 @@
|
||||
"""采集路由。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from src.api.deps import require_admin_key
|
||||
from src.api.deps import require_admin
|
||||
from src.api.schemas import IngestBzzoiroRequest, IngestResponse, IngestUnderstatRequest, IngestInjuriesRequest, IngestSimpleResponse
|
||||
from src.data.config import BZZOIRO_LEAGUE_IDS, FDCO_TO_UNDERSTAT
|
||||
from src.data.sources import get_source
|
||||
from src.data.injuries import ingest_injuries
|
||||
from src.db.unit_of_work import get_uow
|
||||
@@ -15,46 +17,108 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["ingest"])
|
||||
|
||||
# 后台采集任务注册表:持强引用防止被 GC
|
||||
_background_tasks: set[asyncio.Task] = set()
|
||||
|
||||
@router.post("/ingest/bzzoiro", response_model=IngestResponse, dependencies=[Depends(require_admin_key)])
|
||||
|
||||
def _spawn(coro) -> None:
|
||||
"""启动后台采集任务;异常已在任务内记录到系统日志。"""
|
||||
task = asyncio.create_task(coro)
|
||||
_background_tasks.add(task)
|
||||
task.add_done_callback(_background_tasks.discard)
|
||||
|
||||
|
||||
@router.post("/ingest/bzzoiro", dependencies=[Depends(require_admin)])
|
||||
async def ingest_bzzoiro_route(req: IngestBzzoiroRequest):
|
||||
"""触发 bzzoiro 采集。"""
|
||||
source = get_source("bzzoiro")
|
||||
# 未指定联赛 = 采集全部已知联赛;未指定状态 = 已完赛 + 未开赛都采集
|
||||
leagues = req.leagues or list(BZZOIRO_LEAGUE_IDS.keys())
|
||||
statuses = [req.status] if req.status else ["finished", "scheduled"]
|
||||
_spawn(_run_bzzoiro(leagues, req.date_from, req.date_to, statuses))
|
||||
return {
|
||||
"ok": True,
|
||||
"message": f"采集任务已启动(后台执行,状态: {', '.join(statuses)}),请在「系统日志」查看进度与结果",
|
||||
}
|
||||
|
||||
|
||||
async def _run_bzzoiro(leagues: list[str], date_from: str | None, date_to: str | None, statuses: list[str]) -> None:
|
||||
"""后台执行 bzzoiro 采集:上游限速时单次可能耗时数分钟,必须脱离请求生命周期。"""
|
||||
try:
|
||||
source = get_source("bzzoiro")
|
||||
merged: dict = {"leagues": {}, "total_inserted": 0, "total_updated": 0, "errors": []}
|
||||
async with get_uow() as session:
|
||||
result = await source.ingest(
|
||||
for st in statuses:
|
||||
r = await source.ingest(
|
||||
session,
|
||||
leagues=req.leagues,
|
||||
date_from=req.date_from,
|
||||
date_to=req.date_to,
|
||||
status=req.status,
|
||||
leagues=leagues,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
status=st,
|
||||
)
|
||||
return IngestResponse(**result)
|
||||
except Exception as e:
|
||||
logger.exception("bzzoiro ingest failed")
|
||||
raise HTTPException(500, "数据采集失败,请查看服务器日志")
|
||||
merged["total_inserted"] += r.get("total_inserted", 0)
|
||||
merged["total_updated"] += r.get("total_updated", 0)
|
||||
merged["errors"].extend(r.get("errors", []))
|
||||
for code, stat in r.get("leagues", {}).items():
|
||||
acc = merged["leagues"].setdefault(code, {"inserted": 0, "updated": 0, "errors": []})
|
||||
acc["inserted"] += stat.get("inserted", 0)
|
||||
acc["updated"] += stat.get("updated", 0)
|
||||
acc["errors"].extend(stat.get("errors", []))
|
||||
league_errors = {c: stat["errors"] for c, stat in merged["leagues"].items() if stat.get("errors")}
|
||||
logger.info(
|
||||
"bzzoiro 采集完成: 新增 %d, 更新 %d, 联赛 %d 个, 状态 %s",
|
||||
merged["total_inserted"], merged["total_updated"], len(merged["leagues"]), statuses,
|
||||
)
|
||||
if league_errors:
|
||||
sample = {c: errs[:1] for c, errs in list(league_errors.items())[:3]}
|
||||
logger.warning("bzzoiro 部分联赛存在错误: %s", sample)
|
||||
if merged["errors"]:
|
||||
logger.warning("bzzoiro 采集错误 %d 条: %s", len(merged["errors"]), merged["errors"][:3])
|
||||
except Exception:
|
||||
logger.exception("bzzoiro 采集任务失败")
|
||||
|
||||
|
||||
@router.post("/ingest/understat", response_model=IngestSimpleResponse, dependencies=[Depends(require_admin_key)])
|
||||
@router.post("/ingest/understat", dependencies=[Depends(require_admin)])
|
||||
async def ingest_understat_route(req: IngestUnderstatRequest):
|
||||
"""触发 understat xG 回填。"""
|
||||
source = get_source("understat")
|
||||
leagues_to_run = [req.league] if req.league else list(FDCO_TO_UNDERSTAT.keys())
|
||||
_spawn(_run_understat(leagues_to_run, req.season))
|
||||
return {"ok": True, "message": "xG 回填任务已启动(后台执行),请在「系统日志」查看结果"}
|
||||
|
||||
|
||||
async def _run_understat(leagues_to_run: list[str], season: int) -> None:
|
||||
try:
|
||||
source = get_source("understat")
|
||||
merged: dict = {"count": 0, "updated": 0, "skipped": 0, "unmatched": 0, "errors": []}
|
||||
async with get_uow() as session:
|
||||
result = await source.ingest(session, league=req.league, season=req.season)
|
||||
return IngestSimpleResponse(**result)
|
||||
except Exception as e:
|
||||
logger.exception("understat ingest failed")
|
||||
raise HTTPException(500, "xG 回填失败,请查看服务器日志")
|
||||
for league in leagues_to_run:
|
||||
r = await source.ingest(session, league=league, season=season)
|
||||
for k in ("count", "updated", "skipped", "unmatched"):
|
||||
merged[k] += r.get(k, 0)
|
||||
merged["errors"].extend(r.get("errors", []))
|
||||
logger.info(
|
||||
"understat 回填完成: 联赛 %d 个, 更新 %d, 未匹配 %d, 错误 %d",
|
||||
len(leagues_to_run), merged["updated"], merged["unmatched"], len(merged["errors"]),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("understat 回填任务失败")
|
||||
|
||||
|
||||
@router.post("/ingest/injuries", response_model=IngestSimpleResponse, dependencies=[Depends(require_admin_key)])
|
||||
@router.post("/ingest/injuries", dependencies=[Depends(require_admin)])
|
||||
async def ingest_injuries_route(req: IngestInjuriesRequest):
|
||||
"""触发伤停采集。"""
|
||||
_spawn(_run_injuries(req.date))
|
||||
return {"ok": True, "message": "伤停采集任务已启动(后台执行),请在「系统日志」查看结果"}
|
||||
|
||||
|
||||
async def _run_injuries(date: str | None) -> None:
|
||||
try:
|
||||
async with get_uow() as session:
|
||||
result = await ingest_injuries(session, date=req.date)
|
||||
return IngestSimpleResponse(**result)
|
||||
except Exception as e:
|
||||
logger.exception("injuries ingest failed")
|
||||
raise HTTPException(500, "伤停采集失败,请查看服务器日志")
|
||||
result = await ingest_injuries(session, date=date)
|
||||
logger.info(
|
||||
"injuries 采集完成: 新增 %d, 更新 %d, 错误 %d",
|
||||
result.get("count", 0), result.get("updated", 0), len(result.get("errors", [])),
|
||||
)
|
||||
if result.get("errors"):
|
||||
logger.warning("injuries 采集错误: %s", result["errors"][:3])
|
||||
except Exception:
|
||||
logger.exception("injuries 采集任务失败")
|
||||
|
||||
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from src.api.deps import require_admin
|
||||
from src.api.schemas import MatchListOut, MatchOut
|
||||
from src.db.base import AsyncSession, get_db_read
|
||||
from src.db.models import League, Match
|
||||
@@ -14,7 +15,7 @@ from src.db.models import League, Match
|
||||
router = APIRouter(prefix="/api/v1", tags=["data"])
|
||||
|
||||
|
||||
@router.get("/leagues", response_model=list[dict])
|
||||
@router.get("/leagues", response_model=list[dict], dependencies=[Depends(require_admin)])
|
||||
async def list_leagues(db: AsyncSession = Depends(get_db_read)):
|
||||
stmt = select(League).order_by(League.name)
|
||||
result = await db.execute(stmt)
|
||||
@@ -64,7 +65,12 @@ async def list_matches(
|
||||
raise HTTPException(400, "date 格式应为 YYYY-MM-DD")
|
||||
q = q.where(Match.match_date >= d, Match.match_date < d + timedelta(days=1))
|
||||
|
||||
rows = (await db.execute(q.order_by(Match.match_date.desc(), Match.id.desc()).limit(limit + 1))).scalars().all()
|
||||
# 未开赛按日期正序(最近的排最前,便于预测);其余按日期倒序(最新赛果在前)
|
||||
if status == "scheduled":
|
||||
order = (Match.match_date.asc(), Match.id.asc())
|
||||
else:
|
||||
order = (Match.match_date.desc(), Match.id.desc())
|
||||
rows = (await db.execute(q.order_by(*order).limit(limit + 1))).scalars().all()
|
||||
has_more = len(rows) > limit
|
||||
rows = rows[:limit]
|
||||
|
||||
|
||||
@@ -7,9 +7,10 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from src.api.deps import require_admin
|
||||
from src.api.schemas import PredictOut, PredictRequest, PredictionOut
|
||||
from src.db.base import AsyncSession, get_db, get_db_read
|
||||
from src.db.models import Prediction
|
||||
from src.db.models import Match, Prediction
|
||||
from src.llm.predict import predict_match, PredictResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -20,6 +21,12 @@ router = APIRouter(prefix="/api/v1", tags=["predict"])
|
||||
@router.post("/predict", response_model=PredictOut)
|
||||
async def predict(req: PredictRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""对一场比赛调 LLM 预测。mode=multi(默认,5专家+终裁)或 single。"""
|
||||
# 已完赛比赛不再支持预测(回测走服务层直调,不受此限)
|
||||
match = await db.get(Match, req.match_id)
|
||||
if match is None:
|
||||
raise HTTPException(404, "match not found")
|
||||
if match.match_status == "finished":
|
||||
raise HTTPException(400, "该比赛已完赛,不再支持预测")
|
||||
try:
|
||||
result = await predict_match(
|
||||
req.match_id,
|
||||
@@ -28,6 +35,9 @@ async def predict(req: PredictRequest, db: AsyncSession = Depends(get_db)):
|
||||
mode=req.mode,
|
||||
)
|
||||
except ValueError as e:
|
||||
msg = str(e)
|
||||
if "已结算" in msg:
|
||||
raise HTTPException(409, msg)
|
||||
logger.warning("predict validation error: %s", e)
|
||||
raise HTTPException(404, "比赛不存在")
|
||||
except RuntimeError as e:
|
||||
@@ -46,6 +56,8 @@ async def predict(req: PredictRequest, db: AsyncSession = Depends(get_db)):
|
||||
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,
|
||||
@@ -54,9 +66,13 @@ async def predict(req: PredictRequest, db: AsyncSession = Depends(get_db)):
|
||||
context=result.context,
|
||||
latency_ms=result.latency_ms,
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/predictions", response_model=list[PredictionOut])
|
||||
@router.get("/predictions", response_model=list[PredictionOut], dependencies=[Depends(require_admin)])
|
||||
async def list_predictions(
|
||||
match_id: int | None = None,
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
@@ -90,7 +106,7 @@ async def list_predictions(
|
||||
]
|
||||
|
||||
|
||||
@router.get("/predictions/{prediction_id}", response_model=PredictionOut)
|
||||
@router.get("/predictions/{prediction_id}", response_model=PredictionOut, dependencies=[Depends(require_admin)])
|
||||
async def get_prediction(prediction_id: int, db: AsyncSession = Depends(get_db_read)):
|
||||
p = await db.get(Prediction, prediction_id)
|
||||
if p is None:
|
||||
|
||||
+9
-5
@@ -1,7 +1,7 @@
|
||||
"""Pydantic schemas。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import date, datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -53,6 +53,8 @@ class PredictOut(BaseModel):
|
||||
mode: str = "single"
|
||||
pred_home_goals: float | None
|
||||
pred_away_goals: float | None
|
||||
alt_pred_home_goals: int | None = None
|
||||
alt_pred_away_goals: int | None = None
|
||||
pred_1x2: str | None
|
||||
subjective_confidence: float | None
|
||||
reasoning: str | None
|
||||
@@ -71,6 +73,8 @@ class PredictionOut(BaseModel):
|
||||
mode: str = "single"
|
||||
pred_home_goals: float | None
|
||||
pred_away_goals: float | None
|
||||
alt_pred_home_goals: int | None = None
|
||||
alt_pred_away_goals: int | None = None
|
||||
pred_1x2: str | None
|
||||
subjective_confidence: float | None
|
||||
reasoning: str | None
|
||||
@@ -82,10 +86,10 @@ class PredictionOut(BaseModel):
|
||||
|
||||
|
||||
class IngestBzzoiroRequest(BaseModel):
|
||||
leagues: list[str] = Field(..., description="联赛代码列表,如 ['E0','SP1']")
|
||||
leagues: list[str] = Field(default_factory=list, description="联赛代码列表,如 ['E0','SP1'];空 = 全部已知联赛")
|
||||
date_from: str | None = None
|
||||
date_to: str | None = None
|
||||
status: str = "finished"
|
||||
status: str | None = Field(None, description="finished/scheduled;空 = 两者都采集")
|
||||
|
||||
|
||||
class IngestResponse(BaseModel):
|
||||
@@ -96,8 +100,8 @@ class IngestResponse(BaseModel):
|
||||
|
||||
|
||||
class IngestUnderstatRequest(BaseModel):
|
||||
league: str = Field(..., description="联赛代码,如 'E0'")
|
||||
season: int = Field(..., description="赛季起始年,如 2025 表示 2025-2026 赛季")
|
||||
league: str | None = Field(None, description="联赛代码,如 'E0';空 = 全部已知联赛")
|
||||
season: int = Field(default_factory=lambda: date.today().year, description="赛季起始年,如 2025 表示 2025-2026 赛季")
|
||||
|
||||
|
||||
class IngestInjuriesRequest(BaseModel):
|
||||
|
||||
+14
-3
@@ -45,10 +45,21 @@ class Settings(BaseSettings):
|
||||
DB_POOL_RECYCLE: int = 1800
|
||||
|
||||
# --- 管理接口鉴权 ---
|
||||
# 采集 / 回测等高成本或写入型接口需要此 Key(请求头 X-API-Key)。
|
||||
# 留空表示「未启用鉴权」(本地开发默认),生产环境必须设置。
|
||||
# 见审查报告 P2-7:ingest/backtest 无鉴权可被任意调用并烧掉 LLM 额度。
|
||||
# 管理后台登录密码(POST /api/v1/auth/login),登录后颁发 HttpOnly Cookie 会话。
|
||||
# 采集 / 回测等高成本或写入型接口同样需要此密码或下方 API Key。
|
||||
# 两者均留空表示「未启用鉴权」(本地开发默认),生产环境必须至少设置一项。
|
||||
# 注意:.env 中的 ADMIN_PASSWORD 是初始值;后台修改密码后以数据库中的
|
||||
# scrypt 哈希为准,建议随后删除此明文项。
|
||||
ADMIN_PASSWORD: str = ""
|
||||
ADMIN_API_KEY: str = ""
|
||||
# 管理后台会话有效期(小时)
|
||||
ADMIN_SESSION_TTL_HOURS: int = 168
|
||||
|
||||
# --- 加密主密钥 ---
|
||||
# 敏感配置(数据源/LLM 的 API Key)入库加密、会话签名都由它派生。
|
||||
# 只存于部署机 .env,切勿入库或提交代码。生成: openssl rand -base64 32
|
||||
# 变更后已加密配置将无法解密(需在后台重新保存)。
|
||||
SECRET_KEY: str = ""
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"""安全原语:对称加密(Fernet/AES)与密码哈希(scrypt)。
|
||||
|
||||
- API Key 等需要原文调用的敏感值:入库前用 SECRET_KEY 派生的 Fernet 密钥加密,
|
||||
存储格式 `enc:v1:<token>`;读取时解密。SECRET_KEY 只存于部署机 .env,不入库。
|
||||
- 管理员密码:只存 scrypt 哈希(单向,不可逆),验证用,永远不需要还原原文。
|
||||
|
||||
`enc:v1:` 前缀 + 透传设计使旧明文数据无需停机即可共存,由启动迁移一次性加密。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac as _hmac
|
||||
import logging
|
||||
import secrets
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
from src.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ENC_PREFIX = "enc:v1:"
|
||||
|
||||
# scrypt 参数(OWASP 推荐: n=2^17 更强,取 n=2^15 平衡 NAS CPU)
|
||||
_SCRYPT_N = 2**15
|
||||
_SCRYPT_R = 8
|
||||
_SCRYPT_P = 1
|
||||
# OpenSSL 默认 maxmem 限制约 32MB,显式放宽到 128MB
|
||||
_SCRYPT_MAXMEM = 128 * 1024 * 1024
|
||||
|
||||
|
||||
def _fernet() -> Fernet:
|
||||
"""由 SECRET_KEY 确定性派生 Fernet 密钥(任意字符串输入均可)。
|
||||
|
||||
SECRET_KEY 未配置时回落派生自 DATABASE_URL(仅为不让开发环境崩溃;
|
||||
生产必须显式配置,否则加密强度受限 —— 启动时会打 warning)。
|
||||
"""
|
||||
raw = settings.SECRET_KEY
|
||||
if not raw:
|
||||
logger.warning(
|
||||
"SECRET_KEY 未设置,加密密钥回落派生自 DATABASE_URL。"
|
||||
"请在 .env 配置强随机 SECRET_KEY(openssl rand -base64 32)。"
|
||||
)
|
||||
raw = f"fallback:{settings.DATABASE_URL}"
|
||||
digest = hashlib.sha256(raw.encode()).digest()
|
||||
return Fernet(base64.urlsafe_b64encode(digest))
|
||||
|
||||
|
||||
def encrypt_value(plaintext: str) -> str:
|
||||
"""加密敏感值,带版本前缀;空值原样返回。"""
|
||||
if not plaintext:
|
||||
return plaintext
|
||||
token = _fernet().encrypt(plaintext.encode()).decode()
|
||||
return f"{_ENC_PREFIX}{token}"
|
||||
|
||||
|
||||
def decrypt_value(stored: str) -> str:
|
||||
"""解密 `enc:v1:` 前缀的值;无前缀(旧明文)原样返回,便于平滑迁移。"""
|
||||
if not stored or not stored.startswith(_ENC_PREFIX):
|
||||
return stored
|
||||
token = stored[len(_ENC_PREFIX):]
|
||||
try:
|
||||
return _fernet().decrypt(token.encode()).decode()
|
||||
except InvalidToken:
|
||||
# 密钥不匹配(通常是 SECRET_KEY 变了):报错而非静默返回错误数据
|
||||
raise ValueError(
|
||||
"敏感配置解密失败:SECRET_KEY 与加密时不一致。"
|
||||
"恢复原 SECRET_KEY 或在后台重新保存对应配置项。"
|
||||
) from None
|
||||
|
||||
|
||||
def is_encrypted(stored: str) -> bool:
|
||||
return bool(stored) and stored.startswith(_ENC_PREFIX)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""scrypt 哈希,存储格式 scrypt$N$r$p$salt_hex$dk_hex。"""
|
||||
salt = secrets.token_bytes(16)
|
||||
dk = hashlib.scrypt(
|
||||
password.encode(), salt=salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P,
|
||||
dklen=32, maxmem=_SCRYPT_MAXMEM,
|
||||
)
|
||||
return f"scrypt${_SCRYPT_N}${_SCRYPT_R}${_SCRYPT_P}${salt.hex()}${dk.hex()}"
|
||||
|
||||
|
||||
def verify_password(password: str, stored: str) -> bool:
|
||||
"""校验密码与存储的 scrypt 哈希是否匹配。"""
|
||||
try:
|
||||
algo, n, r, p, salt_hex, dk_hex = stored.split("$")
|
||||
if algo != "scrypt":
|
||||
return False
|
||||
dk = hashlib.scrypt(
|
||||
password.encode(),
|
||||
salt=bytes.fromhex(salt_hex),
|
||||
n=int(n),
|
||||
r=int(r),
|
||||
p=int(p),
|
||||
dklen=len(bytes.fromhex(dk_hex)),
|
||||
maxmem=_SCRYPT_MAXMEM,
|
||||
)
|
||||
return _hmac.compare_digest(dk, bytes.fromhex(dk_hex))
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
@@ -0,0 +1,82 @@
|
||||
"""内存日志缓冲:供后台「系统日志」页查看应用运行日志。
|
||||
|
||||
把应用日志(stdout)同时捕获到进程内环形缓冲(deque),提供级别/关键字/条数
|
||||
过滤查询。缓冲在进程重启后清零;需要持久化的审计请另行落库。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from collections import deque
|
||||
|
||||
_BUFFER: deque[dict] = deque(maxlen=2000)
|
||||
_LOCK = threading.Lock()
|
||||
|
||||
_LEVEL_ORDER = {"DEBUG": 10, "INFO": 20, "WARNING": 30, "ERROR": 40, "CRITICAL": 50}
|
||||
|
||||
|
||||
class MemoryLogHandler(logging.Handler):
|
||||
"""把日志记录写入内存环形缓冲。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
# format() 会在有 exc_info 时自动附带异常堆栈文本
|
||||
self.setFormatter(logging.Formatter("%(message)s"))
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
try:
|
||||
entry = {
|
||||
"ts": record.created,
|
||||
"level": record.levelname,
|
||||
"logger": record.name,
|
||||
"message": self.format(record),
|
||||
}
|
||||
with _LOCK:
|
||||
_BUFFER.append(entry)
|
||||
except Exception: # noqa: BLE001 日志采集绝不影响业务
|
||||
self.handleError(record)
|
||||
|
||||
|
||||
class _SQLNoiseFilter(logging.Filter):
|
||||
"""过滤 SQLAlchemy 的 DEBUG/INFO 回显(只留警告以上)。"""
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
return not (
|
||||
record.name.startswith("sqlalchemy.") and record.levelno < logging.WARNING
|
||||
)
|
||||
|
||||
|
||||
def get_entries(
|
||||
min_level: str | None = None,
|
||||
keyword: str | None = None,
|
||||
limit: int = 200,
|
||||
) -> list[dict]:
|
||||
"""按条件查询缓冲日志,最新在前。"""
|
||||
min_no = _LEVEL_ORDER.get((min_level or "").upper(), 0)
|
||||
kw = (keyword or "").strip().lower()
|
||||
with _LOCK:
|
||||
items = list(_BUFFER)
|
||||
items.reverse()
|
||||
out: list[dict] = []
|
||||
for e in items:
|
||||
if _LEVEL_ORDER.get(e["level"], 0) < min_no:
|
||||
continue
|
||||
if kw and kw not in e["message"].lower() and kw not in e["logger"].lower():
|
||||
continue
|
||||
out.append(e)
|
||||
if len(out) >= limit:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def setup_memory_logging(level: str = "INFO") -> None:
|
||||
"""挂载内存 handler 到 root logger(幂等),并确保 root 级别不低于 INFO。"""
|
||||
root = logging.getLogger()
|
||||
if any(isinstance(h, MemoryLogHandler) for h in root.handlers):
|
||||
return
|
||||
handler = MemoryLogHandler()
|
||||
handler.setLevel(logging.INFO)
|
||||
handler.addFilter(_SQLNoiseFilter())
|
||||
root.addHandler(handler)
|
||||
if root.level == logging.NOTSET or root.level > logging.INFO:
|
||||
root.setLevel(getattr(logging, level.upper(), logging.INFO))
|
||||
@@ -0,0 +1,259 @@
|
||||
"""运行时配置:数据库优先,回落 .env。
|
||||
|
||||
后台「数据源」页可在线修改的配置项存 app_settings 表;
|
||||
读取时 DB 有值用 DB,否则回落同名环境变量(pydantic settings)。
|
||||
DB 读取失败时也回落环境变量,保证采集不因管理表故障而中断。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
from src.core import crypto
|
||||
from src.core.config import settings
|
||||
from src.db.base import AsyncSessionLocal
|
||||
from src.db.models import AppSetting
|
||||
|
||||
# 管理员密码哈希在 app_settings 中的键(不进 SETTING_DEFS 白名单:
|
||||
# 只能走专门的改密接口 —— 需验证当前密码,不能被通用配置接口绕过)
|
||||
ADMIN_PASSWORD_HASH_KEY = "ADMIN_PASSWORD_HASH"
|
||||
# .env 明文密码的键名(仅作为初始值;后台改密后以哈希为准)
|
||||
_ADMIN_ENV_KEY = "ADMIN_PASSWORD"
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SettingDef:
|
||||
key: str
|
||||
label: str
|
||||
description: str
|
||||
sensitive: bool
|
||||
|
||||
|
||||
# 允许在后台查看/修改的配置项白名单(之外的 key 一律拒绝读写)
|
||||
SETTING_DEFS: dict[str, SettingDef] = {
|
||||
"BZZOIRO_KEY": SettingDef(
|
||||
"BZZOIRO_KEY", "Bzzoiro API Key", "比赛赛程 / 比分数据源凭证", sensitive=True,
|
||||
),
|
||||
"BZZOIRO_BASE": SettingDef(
|
||||
"BZZOIRO_BASE", "Bzzoiro API 地址", "Bzzoiro 接口基础地址", sensitive=False,
|
||||
),
|
||||
"API_FOOTBALL_KEY": SettingDef(
|
||||
"API_FOOTBALL_KEY", "API-Football Key", "伤停数据源凭证(api-sports)", sensitive=True,
|
||||
),
|
||||
"LLM_API_KEY": SettingDef(
|
||||
"LLM_API_KEY", "LLM API Key", "大模型服务凭证(OpenAI 兼容接口)", sensitive=True,
|
||||
),
|
||||
"LLM_BASE_URL": SettingDef(
|
||||
"LLM_BASE_URL", "LLM 接口地址", "如 https://api.deepseek.com/v1", sensitive=False,
|
||||
),
|
||||
"LLM_MODEL": SettingDef(
|
||||
"LLM_MODEL", "LLM 模型", "如 deepseek-chat / gpt-4o", sensitive=False,
|
||||
),
|
||||
}
|
||||
|
||||
# ── 按角色独立配置 LLM 的键(5 专家 + 终裁) ──
|
||||
# 每个角色可独立覆盖 模型 / 接口地址 / API Key;留空继承分层默认(见 orchestrator._agent_provider)。
|
||||
AGENT_META: list[dict] = [
|
||||
{"id": "form", "label": "近期状态分析专家"},
|
||||
{"id": "stats", "label": "攻防数据分析专家"},
|
||||
{"id": "home_away", "label": "主客因素分析专家"},
|
||||
{"id": "injuries", "label": "阵容完整性分析专家"},
|
||||
{"id": "h2h", "label": "历史交锋分析专家"},
|
||||
{"id": "aggregator", "label": "终裁分析专家"},
|
||||
]
|
||||
|
||||
for _agent in AGENT_META:
|
||||
_u = _agent["id"].upper()
|
||||
SETTING_DEFS[f"AGENT_{_u}_MODEL"] = SettingDef(
|
||||
f"AGENT_{_u}_MODEL", f"{_agent['label']} 模型", "留空继承默认(专家层/全局)", sensitive=False,
|
||||
)
|
||||
SETTING_DEFS[f"AGENT_{_u}_BASE_URL"] = SettingDef(
|
||||
f"AGENT_{_u}_BASE_URL", f"{_agent['label']} 接口地址", "留空继承全局 LLM_BASE_URL", sensitive=False,
|
||||
)
|
||||
SETTING_DEFS[f"AGENT_{_u}_API_KEY"] = SettingDef(
|
||||
f"AGENT_{_u}_API_KEY", f"{_agent['label']} API Key", "留空继承全局 LLM_API_KEY", sensitive=True,
|
||||
)
|
||||
|
||||
|
||||
def mask_value(value: str, sensitive: bool) -> str:
|
||||
"""脱敏展示:敏感值只留末 4 位;非敏感值原样返回。"""
|
||||
if not value:
|
||||
return ""
|
||||
if not sensitive:
|
||||
return value
|
||||
return f"****{value[-4:]}" if len(value) >= 8 else "****"
|
||||
|
||||
|
||||
async def get_runtime_value(key: str) -> str:
|
||||
"""读运行时配置:DB 覆盖值 → .env 默认值 → 空串。
|
||||
|
||||
敏感项入库时是密文,读出后自动解密;旧明文(迁移前)由 decrypt_value 透传。
|
||||
"""
|
||||
defn = SETTING_DEFS.get(key)
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
row = await db.get(AppSetting, key)
|
||||
if row and row.value:
|
||||
value = crypto.decrypt_value(row.value) if defn and defn.sensitive else row.value
|
||||
if value:
|
||||
return value
|
||||
except Exception:
|
||||
logger.warning("读取运行时配置 %s 失败,回落环境变量", key)
|
||||
return getattr(settings, key, "") or ""
|
||||
|
||||
|
||||
async def set_runtime_value(key: str, value: str) -> None:
|
||||
"""写入/更新 DB 覆盖值(调用方需先校验 key 在白名单内)。
|
||||
|
||||
敏感项(SETTING_DEFS.sensitive)以 Fernet 加密存储,库里不落明文。
|
||||
"""
|
||||
defn = SETTING_DEFS.get(key)
|
||||
stored = crypto.encrypt_value(value) if defn and defn.sensitive else value
|
||||
async with AsyncSessionLocal() as db:
|
||||
stmt = pg_insert(AppSetting).values(key=key, value=stored)
|
||||
stmt = stmt.on_conflict_do_update(index_elements=["key"], set_={"value": stored})
|
||||
await db.execute(stmt)
|
||||
await db.commit()
|
||||
logger.info("运行时配置 %s 已更新", key)
|
||||
|
||||
|
||||
async def clear_runtime_value(key: str) -> None:
|
||||
"""删除 DB 覆盖值,回落 .env(调用方需先校验 key 在白名单内)。"""
|
||||
async with AsyncSessionLocal() as db:
|
||||
row = await db.get(AppSetting, key)
|
||||
if row is not None:
|
||||
await db.delete(row)
|
||||
await db.commit()
|
||||
logger.info("运行时配置 %s 已清除覆盖", key)
|
||||
|
||||
|
||||
async def get_setting_origin(key: str) -> tuple[str, str]:
|
||||
"""返回 (origin, 当前生效值)。origin ∈ db / env / none。"""
|
||||
defn = SETTING_DEFS.get(key)
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
row = await db.get(AppSetting, key)
|
||||
if row and row.value:
|
||||
value = crypto.decrypt_value(row.value) if defn and defn.sensitive else row.value
|
||||
return "db", value
|
||||
except Exception:
|
||||
logger.warning("读取运行时配置 %s 来源失败,按环境变量处理", key)
|
||||
env_value = getattr(settings, key, "") or ""
|
||||
return ("env", env_value) if env_value else ("none", "")
|
||||
|
||||
|
||||
async def migrate_plaintext_sensitive_settings() -> int:
|
||||
"""一次性迁移:把库中仍是明文的敏感项加密(幂等,启动时执行)。
|
||||
|
||||
返回加密的条数。
|
||||
"""
|
||||
migrated = 0
|
||||
sensitive_keys = {k for k, d in SETTING_DEFS.items() if d.sensitive}
|
||||
async with AsyncSessionLocal() as db:
|
||||
rows = (await db.execute(select(AppSetting))).scalars().all()
|
||||
for row in rows:
|
||||
if row.key not in sensitive_keys or crypto.is_encrypted(row.value):
|
||||
continue
|
||||
row.value = crypto.encrypt_value(row.value)
|
||||
migrated += 1
|
||||
await db.commit()
|
||||
if migrated:
|
||||
logger.info("已加密迁移 %d 条明文敏感配置", migrated)
|
||||
return migrated
|
||||
|
||||
|
||||
# ── 管理员密码:只存 scrypt 哈希,永不存明文 ──────────────────────
|
||||
|
||||
|
||||
async def get_admin_password_hash() -> str:
|
||||
"""库中管理员密码哈希;无则空串。"""
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
row = await db.get(AppSetting, ADMIN_PASSWORD_HASH_KEY)
|
||||
return row.value if row else ""
|
||||
except Exception:
|
||||
logger.warning("读取管理员密码哈希失败")
|
||||
return ""
|
||||
|
||||
|
||||
async def set_admin_password_hash(hash_str: str) -> None:
|
||||
async with AsyncSessionLocal() as db:
|
||||
stmt = pg_insert(AppSetting).values(key=ADMIN_PASSWORD_HASH_KEY, value=hash_str)
|
||||
stmt = stmt.on_conflict_do_update(index_elements=["key"], set_={"value": hash_str})
|
||||
await db.execute(stmt)
|
||||
await db.commit()
|
||||
logger.info("管理员密码哈希已更新")
|
||||
|
||||
|
||||
async def verify_admin_password(candidate: str) -> bool:
|
||||
"""校验管理员密码:优先哈希;哈希不存在时回落 .env 明文(未迁移的旧部署)。"""
|
||||
stored_hash = await get_admin_password_hash()
|
||||
if stored_hash:
|
||||
return crypto.verify_password(candidate, stored_hash)
|
||||
env_pw = getattr(settings, _ADMIN_ENV_KEY, "") or ""
|
||||
return bool(env_pw) and secrets.compare_digest(candidate, env_pw)
|
||||
|
||||
|
||||
async def get_admin_credential_fingerprint() -> str:
|
||||
"""管理员凭证指纹(作为会话签名密钥的输入)。
|
||||
|
||||
用密码哈希而非密码本身:凭证变化 → 指纹变化 → 全部会话失效。
|
||||
"""
|
||||
stored_hash = await get_admin_password_hash()
|
||||
if stored_hash:
|
||||
return f"hash:{stored_hash}"
|
||||
env_pw = getattr(settings, _ADMIN_ENV_KEY, "") or ""
|
||||
return f"env:{env_pw}" if env_pw else ""
|
||||
|
||||
|
||||
async def _get_raw_setting(key: str) -> str:
|
||||
async with AsyncSessionLocal() as db:
|
||||
row = await db.get(AppSetting, key)
|
||||
return row.value if row else ""
|
||||
|
||||
|
||||
async def _delete_setting(key: str) -> None:
|
||||
async with AsyncSessionLocal() as db:
|
||||
row = await db.get(AppSetting, key)
|
||||
if row is not None:
|
||||
await db.delete(row)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def ensure_admin_password_hashed() -> bool:
|
||||
"""启动迁移:确保管理员密码只以 scrypt 哈希存在(幂等)。
|
||||
|
||||
迁移来源优先级:
|
||||
1. 库中旧版明文 ADMIN_PASSWORD 行(旧代码写入的当前密码,迁移后删除该明文行)
|
||||
2. .env 的 ADMIN_PASSWORD 初始值
|
||||
"""
|
||||
if await get_admin_password_hash():
|
||||
# 哈希已存在:清除旧版可能残留的明文行
|
||||
if await _get_raw_setting(_ADMIN_ENV_KEY):
|
||||
await _delete_setting(_ADMIN_ENV_KEY)
|
||||
logger.info("已删除遗留的明文 ADMIN_PASSWORD 行(哈希已存在)")
|
||||
return False
|
||||
|
||||
legacy_plain = await _get_raw_setting(_ADMIN_ENV_KEY)
|
||||
if legacy_plain:
|
||||
await set_admin_password_hash(crypto.hash_password(legacy_plain))
|
||||
await _delete_setting(_ADMIN_ENV_KEY)
|
||||
logger.info("已将库中明文管理员密码迁移为 scrypt 哈希,明文行已删除")
|
||||
return True
|
||||
|
||||
env_pw = getattr(settings, _ADMIN_ENV_KEY, "") or ""
|
||||
if not env_pw:
|
||||
return False
|
||||
await set_admin_password_hash(crypto.hash_password(env_pw))
|
||||
logger.info(
|
||||
"已将 .env 中的明文管理员密码迁移为 scrypt 哈希。"
|
||||
"建议现在从 .env 中删除 ADMIN_PASSWORD 明文行。"
|
||||
)
|
||||
return True
|
||||
+20
-9
@@ -14,10 +14,13 @@ from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.core.config import settings
|
||||
import httpx
|
||||
|
||||
from src.core.runtime_config import get_runtime_value
|
||||
from src.core.http_client import get_client
|
||||
from src.data.config import BZZOIRO_LEAGUE_IDS, LEAGUE_COUNTRIES, LEAGUE_NAMES, REQUEST_INTERVAL
|
||||
from src.data.normalize import normalize_bzzoiro
|
||||
from src.data.team_names_zh import zh_name
|
||||
from src.data.sources import register
|
||||
from src.db.models import League, Match, MatchStats, Team
|
||||
|
||||
@@ -46,9 +49,9 @@ def _match_key(home_team_id: int, away_team_id: int, match_date) -> tuple[int, i
|
||||
|
||||
async def _fetch_json_async(path: str, params: dict | None = None, max_retries: int = 3) -> dict | list:
|
||||
"""异步 HTTP(bzzoiro 使用 httpx,不再阻塞事件循环线程池)。"""
|
||||
base = settings.BZZOIRO_BASE.rstrip("/")
|
||||
base = (await get_runtime_value("BZZOIRO_BASE")).rstrip("/")
|
||||
url = f"{base}/{path.lstrip('/')}"
|
||||
key = settings.BZZOIRO_KEY
|
||||
key = await get_runtime_value("BZZOIRO_KEY")
|
||||
if not key:
|
||||
raise RuntimeError("BZZOIRO_KEY 未设置")
|
||||
|
||||
@@ -61,7 +64,14 @@ async def _fetch_json_async(path: str, params: dict | None = None, max_retries:
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
client = get_client()
|
||||
resp = await client.get(url, headers=headers, params=params, timeout=30)
|
||||
# 整请求兜底: httpx 无 total 超时,用 wait_for 防「滴水式」限速挂死
|
||||
resp = await asyncio.wait_for(
|
||||
client.get(
|
||||
url, headers=headers, params=params,
|
||||
timeout=httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0),
|
||||
),
|
||||
timeout=60.0,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except Exception as e:
|
||||
@@ -199,7 +209,8 @@ class BzzoiroSource:
|
||||
# 避免加载联赛全部历史比赛到内存(多赛季采集时内存溢出)
|
||||
if normalized_matches:
|
||||
from datetime import timedelta
|
||||
dates = [nm.date for nm in normalized_matches if nm.date is not None]
|
||||
# normalized_matches 存的是 (nm, raw) 元组,遍历需解包
|
||||
dates = [nm.date for nm, _raw in normalized_matches if nm.date is not None]
|
||||
if dates:
|
||||
min_dt = min(dates) - timedelta(days=30)
|
||||
max_dt = max(dates) + timedelta(days=30)
|
||||
@@ -219,7 +230,7 @@ class BzzoiroSource:
|
||||
# 球队: 内存查找 + 按需创建
|
||||
home_team_id = team_name_to_id.get(nm.home_team)
|
||||
if home_team_id is None:
|
||||
home = Team(name=nm.home_team)
|
||||
home = Team(name=nm.home_team, name_zh=zh_name(nm.home_team))
|
||||
db.add(home)
|
||||
await db.flush()
|
||||
home_team_id = home.id
|
||||
@@ -227,7 +238,7 @@ class BzzoiroSource:
|
||||
|
||||
away_team_id = team_name_to_id.get(nm.away_team)
|
||||
if away_team_id is None:
|
||||
away = Team(name=nm.away_team)
|
||||
away = Team(name=nm.away_team, name_zh=zh_name(nm.away_team))
|
||||
db.add(away)
|
||||
await db.flush()
|
||||
away_team_id = away.id
|
||||
@@ -273,7 +284,7 @@ class BzzoiroSource:
|
||||
home_red_cards=nm.home_red_cards,
|
||||
away_red_cards=nm.away_red_cards,
|
||||
source="bzzoiro",
|
||||
source_event_id=str(raw.get("id", "")),
|
||||
source_record_id=str(raw.get("id", "")),
|
||||
retrieved_at=now,
|
||||
available_at=now,
|
||||
)
|
||||
@@ -299,7 +310,7 @@ class BzzoiroSource:
|
||||
existing_match.stats = MatchStats(
|
||||
match_id=existing_match.id,
|
||||
source="bzzoiro",
|
||||
source_event_id=str(raw.get("id", "")),
|
||||
source_record_id=str(raw.get("id", "")),
|
||||
retrieved_at=now,
|
||||
available_at=now,
|
||||
)
|
||||
|
||||
+1
-1
@@ -43,4 +43,4 @@ LEAGUE_COUNTRIES: dict[str, str] = {
|
||||
"EL": "Europe",
|
||||
}
|
||||
|
||||
REQUEST_INTERVAL = 1.2 # bzzoiro 限速(秒)
|
||||
REQUEST_INTERVAL = 2.0 # bzzoiro 限速(秒);上游限速严厉时宁可慢一点
|
||||
|
||||
@@ -16,7 +16,7 @@ from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from src.core.config import settings
|
||||
from src.core.runtime_config import get_runtime_value
|
||||
from src.core.http_client import get_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -39,7 +39,7 @@ async def fetch_injuries(*, date: str | None = None, fixture_id: int | None = No
|
||||
Returns:
|
||||
伤停记录列表
|
||||
"""
|
||||
api_key = settings.API_FOOTBALL_KEY
|
||||
api_key = await get_runtime_value("API_FOOTBALL_KEY")
|
||||
if not api_key:
|
||||
raise RuntimeError("API_FOOTBALL_KEY 未设置")
|
||||
|
||||
@@ -77,7 +77,13 @@ async def fetch_injuries(*, date: str | None = None, fixture_id: int | None = No
|
||||
for attempt in range(3):
|
||||
try:
|
||||
client = get_client()
|
||||
resp = await client.get(url, headers=headers, params=params, timeout=30)
|
||||
resp = await asyncio.wait_for(
|
||||
client.get(
|
||||
url, headers=headers, params=params,
|
||||
timeout=httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0),
|
||||
),
|
||||
timeout=60.0,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
break
|
||||
except Exception as e:
|
||||
|
||||
@@ -18,7 +18,7 @@ VALID_STATUS = {"finished", "scheduled", "in_play", "paused", "postponed", "canc
|
||||
|
||||
STATUS_MAP = {
|
||||
"finished": "finished", "completed": "finished", "done": "finished", "awarded": "finished",
|
||||
"scheduled": "scheduled", "upcoming": "scheduled",
|
||||
"scheduled": "scheduled", "upcoming": "scheduled", "notstarted": "scheduled", "not_started": "scheduled",
|
||||
"in_play": "in_play", "live": "in_play",
|
||||
"paused": "paused", "postponed": "postponed",
|
||||
"cancelled": "cancelled", "canceled": "cancelled", "abandoned": "cancelled",
|
||||
@@ -200,8 +200,8 @@ def normalize_understat(raw: dict, league_type: str) -> NormalizedMatch | None:
|
||||
away = normalize_name(away_name)
|
||||
if not home or not away or home == away:
|
||||
return None
|
||||
home_xg = raw.get("xG", {}).get("h") if isinstance(raw.get("xG"), dict) else None
|
||||
away_xg = raw.get("xG", {}).get("a") if isinstance(raw.get("xG"), dict) else None
|
||||
home_xg = _to_float(raw["xG"].get("h")) if isinstance(raw.get("xG"), dict) else None
|
||||
away_xg = _to_float(raw["xG"].get("a")) if isinstance(raw.get("xG"), dict) else None
|
||||
return NormalizedMatch(
|
||||
league_type=league_type,
|
||||
date=dt,
|
||||
|
||||
+7
-3
@@ -29,9 +29,13 @@ class DataSource(Protocol):
|
||||
_SOURCES: dict[str, DataSource] = {}
|
||||
|
||||
|
||||
def register(source: DataSource) -> DataSource:
|
||||
"""装饰器:将数据源注册到全局注册表。"""
|
||||
_SOURCES[source.name] = source
|
||||
def register(source):
|
||||
"""装饰器:将数据源注册到全局注册表。
|
||||
|
||||
兼容类注册与实例注册:类会被实例化后存入(保证 get_source 返回实例)。
|
||||
"""
|
||||
obj = source() if isinstance(source, type) else source
|
||||
_SOURCES[obj.name] = obj
|
||||
return source
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
"""球队中文译名表: 规范化英文名 → 中文。
|
||||
|
||||
来源: 手工整理(五大联赛全部 + 欧战常客)。
|
||||
未收录的球队保持英文显示(前端回落),新队采集入库时自动查此表。
|
||||
"""
|
||||
|
||||
TEAM_NAME_ZH: dict[str, str] = {
|
||||
# ── 英格兰 ──
|
||||
"Arsenal": "阿森纳", "Aston Villa": "阿斯顿维拉", "Chelsea": "切尔西",
|
||||
"Liverpool FC": "利物浦", "Liverpool": "利物浦",
|
||||
"Manchester City": "曼城", "Manchester United": "曼联",
|
||||
"Tottenham Hotspur": "托特纳姆热刺", "Newcastle United": "纽卡斯尔联",
|
||||
"West Ham United": "西汉姆联", "Everton": "埃弗顿", "Fulham": "富勒姆",
|
||||
"Crystal Palace": "水晶宫", "Brentford": "布伦特福德",
|
||||
"Brighton & Hove Albion": "布莱顿", "Brighton and Hove Albion": "布莱顿",
|
||||
"Wolverhampton": "狼队", "Wolverhampton Wanderers": "狼队",
|
||||
"Nottingham Forest": "诺丁汉森林", "AFC Bournemouth": "伯恩茅斯",
|
||||
"Leeds United": "利兹联", "Leicester City": "莱斯特城",
|
||||
"Ipswich Town": "伊普斯维奇", "Southampton": "南安普顿",
|
||||
"Norwich City": "诺维奇城", "Sheffield United": "谢菲尔德联",
|
||||
"Sheffield Wednesday": "谢周三", "Stoke City": "斯托克城",
|
||||
"Sunderland": "桑德兰", "Burnley": "伯恩利", "Watford": "沃特福德",
|
||||
"Hull City": "赫尔城", "Huddersfield Town": "哈德斯菲尔德",
|
||||
"Luton Town": "卢顿", "Cardiff City": "加的夫城", "Swansea City": "斯旺西",
|
||||
"West Bromwich Albion": "西布罗姆维奇", "Birmingham City": "伯明翰",
|
||||
"Blackburn Rovers": "布莱克本", "Bolton Wanderers": "博尔顿",
|
||||
"Barnsley": "巴恩斯利", "Blackpool": "布莱克浦",
|
||||
"Bradford City": "布拉德福德", "Charlton Athletic": "查尔顿竞技",
|
||||
"Coventry City": "考文垂", "Derby County": "德比郡",
|
||||
"Middlesbrough": "米德尔斯堡", "Milton Keynes Dons": "米尔顿凯恩斯",
|
||||
"Oldham Athletic": "奥尔德姆竞技", "Portsmouth": "朴茨茅斯",
|
||||
"Queens Park Rangers": "女王公园巡游者", "Reading": "雷丁",
|
||||
"Swindon Town": "斯温登", "Wigan Athletic": "维冈竞技",
|
||||
# ── 苏格兰/爱尔兰 ──
|
||||
"Celtic": "凯尔特人", "Rangers": "流浪者", "Aberdeen": "阿伯丁",
|
||||
"Heart of Midlothian": "哈茨", "Hibernian": "希伯尼安",
|
||||
"Derry City": "德里城", "Shelbourne": "谢尔本", "Larne FC": "拉恩",
|
||||
"Linfield FC": "林斯菲尔德", "Shamrock Rovers": "沙姆罗克流浪者",
|
||||
# ── 西班牙 ──
|
||||
"Real Madrid": "皇家马德里", "FC Barcelona": "巴塞罗那",
|
||||
"Atlético Madrid": "马德里竞技", "Athletic Club": "毕尔巴鄂竞技",
|
||||
"Real Sociedad": "皇家社会", "Villarreal": "比利亚雷亚尔",
|
||||
"Real Betis": "皇家贝蒂斯", "Sevilla": "塞维利亚", "Valencia": "瓦伦西亚",
|
||||
"Celta Vigo": "塞尔塔", "Osasuna": "奥萨苏纳", "Getafe": "赫塔菲",
|
||||
"Rayo Vallecano": "巴列卡诺", "Mallorca": "马略卡", "Girona FC": "赫罗纳",
|
||||
"Girona": "赫罗纳", "Espanyol": "西班牙人", "UD Las Palmas": "拉斯帕尔马斯",
|
||||
"Las Palmas": "拉斯帕尔马斯", "Deportivo Alavés": "阿拉维斯",
|
||||
"Leganés": "莱加内斯", "Elche": "埃尔切", "Levante UD": "莱万特",
|
||||
"Malaga CF": "马拉加", "Deportivo de A Coruna": "拉科鲁尼亚",
|
||||
"Real Oviedo": "皇家奥维耶多", "Real Racing Club": "桑坦德竞技",
|
||||
"Real Valladolid": "巴利亚多利德",
|
||||
# ── 意大利 ──
|
||||
"Juventus": "尤文图斯", "AC Milan": "AC米兰", "Inter Milan": "国际米兰",
|
||||
"SSC Napoli": "那不勒斯", "AS Roma": "罗马", "Lazio": "拉齐奥",
|
||||
"Atalanta": "亚特兰大", "ACF Fiorentina": "佛罗伦萨", "Bologna": "博洛尼亚",
|
||||
"Torino": "都灵", "Udinese": "乌迪内斯", "Genoa": "热那亚",
|
||||
"Cagliari": "卡利亚里", "Hellas Verona": "维罗纳", "Lecce": "莱切",
|
||||
"Empoli": "恩波利", "Parma": "帕尔马", "Como": "科莫", "Venezia": "威尼斯",
|
||||
"Pisa": "比萨", "Cremonese": "克雷莫纳", "AC Monza": "蒙扎",
|
||||
"Frosinone": "弗罗西诺内", "Sassuolo": "萨索洛",
|
||||
# ── 德国 ──
|
||||
"FC Bayern Munchen": "拜仁慕尼黑", "Borussia Dortmund": "多特蒙德",
|
||||
"Bayer 04 Leverkusen": "勒沃库森", "RB Leipzig": "莱比锡红牛",
|
||||
"Borussia Mönchengladbach": "门兴格拉德巴赫", "VfB Stuttgart": "斯图加特",
|
||||
"Eintracht Frankfurt": "法兰克福", "VfL Wolfsburg": "沃尔夫斯堡",
|
||||
"SC Freiburg": "弗赖堡", "TSG Hoffenheim": "霍芬海姆",
|
||||
"1. FC Union Berlin": "柏林联合", "1. FC Koln": "科隆",
|
||||
"1. FSV Mainz 05": "美因茨", "FC Augsburg": "奥格斯堡",
|
||||
"SV Werder Bremen": "云达不来梅", "VfL Bochum 1848": "波鸿",
|
||||
"1. FC Heidenheim": "海登海姆", "FC St. Pauli": "圣保利",
|
||||
"Holstein Kiel": "荷尔斯泰因基尔", "FC Schalke 04": "沙尔克04",
|
||||
"Hamburger SV": "汉堡", "SC Paderborn 07": "帕德博恩",
|
||||
"SV 07 Elversberg": "埃弗斯贝格",
|
||||
# ── 法国 ──
|
||||
"Paris Saint-Germain": "巴黎圣日耳曼", "Olympique de Marseille": "马赛",
|
||||
"Olympique Lyonnais": "里昂", "AS Monaco": "摩纳哥", "Lille OSC": "里尔",
|
||||
"OGC Nice": "尼斯", "RC Lens": "朗斯", "Stade Rennais": "雷恩",
|
||||
"RC Strasbourg": "斯特拉斯堡", "Stade Brestois": "布雷斯特",
|
||||
"Stade de Reims": "兰斯", "FC Nantes": "南特", "Toulouse FC": "图卢兹",
|
||||
"Montpellier HSC": "蒙彼利埃", "AS Saint-Étienne": "圣埃蒂安",
|
||||
"AJ Auxerre": "欧塞尔", "Le Havre AC": "勒阿弗尔", "FC Lorient": "洛里昂",
|
||||
"Metz": "梅斯", "Angers SCO": "昂热", "Guingamp": "甘冈", "Troyes": "特鲁瓦",
|
||||
"Le Mans": "勒芒", "Paris FC": "巴黎FC", "USL Dunkerque": "敦刻尔克",
|
||||
"Rodez AF": "罗德兹", "Red Star FC": "巴黎红星",
|
||||
# ── 荷兰/比利时 ──
|
||||
"AFC Ajax": "阿贾克斯", "PSV Eindhoven": "埃因霍温", "Feyenoord": "费耶诺德",
|
||||
"AZ Alkmaar": "阿尔克马尔", "FC Twente": "特温特", "FC Utrecht": "乌得勒支",
|
||||
"NEC Nijmegen": "奈梅亨", "Go Ahead Eagles": "前进之鹰",
|
||||
"Club Brugge KV": "布鲁日", "RSC Anderlecht": "安德莱赫特",
|
||||
"KRC Genk": "亨克", "Royale Union Saint-Gilloise": "圣吉罗斯联合",
|
||||
"Sint-Truidense VV": "圣特鲁伊登",
|
||||
# ── 葡萄牙 ──
|
||||
"FC Porto": "波尔图", "Benfica": "本菲卡", "Sporting CP": "里斯本竞技",
|
||||
"Sporting Braga": "布拉加", "Torreense": "托雷恩塞",
|
||||
# ── 土耳其 ──
|
||||
"Galatasaray": "加拉塔萨雷", "Fenerbahce": "费内巴切",
|
||||
"Besiktas JK": "贝西克塔斯", "Trabzonspor": "特拉布宗体育",
|
||||
"Samsunspor": "萨姆松体育",
|
||||
# ── 北欧 ──
|
||||
"Bodø/Glimt": "博多闪耀", "Viking FK": "维京", "Tromsø IL": "特罗姆瑟",
|
||||
"SK Brann": "布兰", "Lillestrøm SK": "利勒斯特罗姆",
|
||||
"Malmo FF": "马尔默", "IF Elfsborg": "埃尔夫斯堡", "BK Hacken": "哈肯",
|
||||
"Hammarby IF": "哈马比", "Fredrikstad FK": "腓特烈斯塔",
|
||||
"Mjallby AIF": "米亚尔比", "AGF": "奥胡斯", "FC Midtjylland": "中日德兰",
|
||||
"FC København": "哥本哈根", "Klaksvikar Itrottarfelag": "克拉克斯维克",
|
||||
"Vikingur Gøta": "戈塔维京人", "Vikingur Reykjavik": "雷克雅未克维京人",
|
||||
"Breidablik Kopavogur": "布雷达布利克", "IF Vestri": "韦斯特里",
|
||||
"Kuopion Palloseura": "库奥皮奥", "Ilves": "伊尔维斯",
|
||||
# ── 瑞士/奥地利 ──
|
||||
"Basel": "巴塞尔", "BSC Young Boys": "伯尔尼年轻人", "FC Lugano": "卢加诺",
|
||||
"Servette FC": "塞尔维特", "FC Thun": "图恩",
|
||||
"FC St. Gallen 1879": "圣加仑", "LASK": "林茨", "SK Sturm Graz": "格拉茨风暴",
|
||||
"Red Bull Salzburg": "萨尔茨堡红牛", "Wolfsberger AC": "沃尔夫斯贝格",
|
||||
# ── 中东欧 ──
|
||||
"Shakhtar Donetsk": "顿涅茨克矿工", "Dynamo Kyiv": "基辅迪纳摩",
|
||||
"Dinamo Minsk": "明斯克迪纳摩", "ML Vitebsk": "维捷布斯克",
|
||||
"Legia Warszawa": "华沙莱吉亚", "Lech Poznan": "波兹南莱赫",
|
||||
"Jagiellonia Białystok": "比亚韦斯托克亚盖隆尼亚",
|
||||
"Gornik Zabrze": "扎布热矿工", "MSK Zilina": "日利纳",
|
||||
"SK Slovan Bratislava": "布拉迪斯拉发斯洛万",
|
||||
"FC Spartak Trnava": "特尔纳瓦斯巴达", "SK Slavia Praha": "布拉格斯拉维亚",
|
||||
"AC Sparta Praha": "布拉格斯巴达", "FC Viktoria Plzen": "比尔森胜利",
|
||||
"SK Sigma Olomouc": "奥洛莫茨西格玛", "FC Hradec Kralove": "赫拉德茨克拉洛韦",
|
||||
"Banik Ostrava": "俄斯特拉发矿工", "Ferencvaros TC": "费伦茨瓦罗斯",
|
||||
"Paksi FC": "帕克斯", "ETO FC Gyor": "杰尔", "CFR 1907 Cluj": "克卢日",
|
||||
"FCSB": "布加勒斯特星", "FC Universitatea Cluj": "克卢日大学",
|
||||
"Universitatea Craiova": "克拉约瓦大学", "Ludogorets": "卢多戈雷茨",
|
||||
"Levski Sofia": "索非亚列夫斯基", "CSKA Sofia": "索非亚中央陆军",
|
||||
"GNK Dinamo Zagreb": "萨格勒布迪纳摩", "HNK Hajduk Split": "斯普利特海杜克",
|
||||
"HNK Rijeka": "里耶卡", "NK Olimpija Ljubljana": "卢布尔雅那奥林匹亚",
|
||||
"NK Celje": "采列", "NK Aluminij Kidricevo": "阿卢米尼",
|
||||
"FK Partizan": "贝尔格莱德游击队", "FK Vojvodina": "伏伊伏丁那",
|
||||
"FK Crvena Zvezda": "贝尔格莱德红星", "FK Crvena zvezda": "贝尔格莱德红星",
|
||||
"FK Borac Banja Luka": "巴尼亚卢卡战士", "HSK Zrinjski Mostar": "莫斯塔尔兹林斯基",
|
||||
"FK Buducnost Podgorica": "波德戈里察未来",
|
||||
"FK Sutjeska Niksic": "苏捷斯卡", "Sheriff Tiraspol": "谢里夫",
|
||||
"FC Petrocub Hincesti": "佩特罗库布", "FC Milsami Orhei": "米尔萨米",
|
||||
# ── 希腊/塞浦路斯/以色列 ──
|
||||
"Olympiacos FC": "奥林匹亚科斯", "Panathinaikos FC": "帕纳辛纳科斯",
|
||||
"PAOK": "塞萨洛尼基PAOK", "AEK Athens": "雅典AEK", "OFI Crete": "克里特OFI",
|
||||
"Omonia Nicosia": "尼科西亚奥莫尼亚", "AEK Larnaca": "拉纳卡AEK",
|
||||
"Pafos FC": "帕福斯", "Hapoel Be'er Sheva": "贝尔谢巴夏普尔",
|
||||
"Maccabi Tel Aviv": "特拉维夫马卡比",
|
||||
# ── 东南欧/高加索/中亚 ──
|
||||
"Qarabag FK": "卡拉巴赫", "Sabah FK": "萨巴赫",
|
||||
"FC Ararat-Armenia": "亚美尼亚阿拉拉特", "FC Noah": "诺亚",
|
||||
"FK Aktobe": "阿克托别", "Kairat Almaty": "阿拉木图凯拉特",
|
||||
"FC Kairat Almaty": "阿拉木图凯拉特",
|
||||
"FK Vardar Skopje": "瓦尔达尔斯科普里", "KF Shkendija": "什肯迪贾",
|
||||
"KF Egnatia": "埃格纳蒂亚", "FK Zalgiris": "萨尔吉里斯",
|
||||
"FK Kauno Zalgiris": "考那斯萨尔吉里斯", "Riga FC": "里加",
|
||||
"RFS": "里加足球学校", "FCI Levadia Tallinn": "塔林列瓦迪亚",
|
||||
"Flora Tallinn": "塔林弗洛拉",
|
||||
# ── 小联赛/外围 ──
|
||||
"The New Saints": "新圣徒", "Lincoln Red Imps": "林肯红魔",
|
||||
"Ħamrun Spartans FC": "哈姆伦斯巴达", "Floriana FC": "弗洛里亚纳",
|
||||
"SP Tre Fiori": "特雷菲奥里", "SS Virtus": "维尔图斯",
|
||||
"Inter Club d'Escaldes": "埃斯卡尔德斯", "Differdange FC 03": "迪费尔当",
|
||||
"Atert Bissen": "比森", "FC Drita": "德里塔", "FC Prishtina": "普里什蒂纳",
|
||||
"FC Iberia 1999": "伊比利亚1999", "FK Buducnost": "波德戈里察未来",
|
||||
}
|
||||
|
||||
|
||||
def zh_name(name: str | None) -> str | None:
|
||||
"""查中文译名;未收录返回 None(由调用方回落英文)。"""
|
||||
if not name:
|
||||
return None
|
||||
return TEAM_NAME_ZH.get(name.strip())
|
||||
+17
-4
@@ -52,7 +52,13 @@ async def fetch_understat(league_code: str, season: int) -> list[dict]:
|
||||
for attempt in range(3):
|
||||
try:
|
||||
client = get_client()
|
||||
resp = await client.get(url, headers=headers, timeout=30)
|
||||
resp = await asyncio.wait_for(
|
||||
client.get(
|
||||
url, headers=headers,
|
||||
timeout=httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0),
|
||||
),
|
||||
timeout=60.0,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
break
|
||||
except Exception as e:
|
||||
@@ -65,9 +71,16 @@ async def fetch_understat(league_code: str, season: int) -> list[dict]:
|
||||
else:
|
||||
raise RuntimeError(f"understat fetch failed: {last_exc}")
|
||||
|
||||
# understat 返回 JS 对象,需要提取 JSON
|
||||
# 优先按 JSON 响应解析(getLeagueData 接口返回 {teams, players, dates})
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
data = None
|
||||
if isinstance(data, dict) and isinstance(data.get("dates"), list):
|
||||
return data["dates"]
|
||||
|
||||
# 兼容旧版联赛页面:内嵌 var datesData = JSON.parse('...')
|
||||
text = resp.text
|
||||
# 匹配 var datesData = JSON.parse('...');
|
||||
match = re.search(r"var\s+datesData\s*=\s*JSON\.parse\('([^']+)'\)", text)
|
||||
if not match:
|
||||
logger.warning("understat 响应格式不符: %s...", text[:200])
|
||||
@@ -187,7 +200,7 @@ class UnderstatSource:
|
||||
existing.stats = MatchStats(
|
||||
match_id=existing.id,
|
||||
source="understat",
|
||||
source_event_id=str(raw.get("id", "")),
|
||||
source_record_id=str(raw.get("id", "")),
|
||||
retrieved_at=now,
|
||||
available_at=now,
|
||||
)
|
||||
|
||||
@@ -177,6 +177,9 @@ class Prediction(Base):
|
||||
latency_ms: Mapped[int | None] = mapped_column(Integer)
|
||||
pred_home_goals: Mapped[float | None] = mapped_column(Float)
|
||||
pred_away_goals: Mapped[float | None] = mapped_column(Float)
|
||||
# 备选比分(次可能比分,可空)
|
||||
alt_pred_home_goals: Mapped[int | None] = mapped_column(Integer)
|
||||
alt_pred_away_goals: Mapped[int | None] = mapped_column(Integer)
|
||||
pred_1x2: Mapped[str | None] = mapped_column(String(3))
|
||||
subjective_confidence: Mapped[float | None] = mapped_column(Float) # LLM 主观置信度,非概率
|
||||
reasoning: Mapped[str | None] = mapped_column(Text)
|
||||
@@ -216,3 +219,12 @@ class Prediction(Base):
|
||||
CheckConstraint("mode IN ('single', 'multi')", name="ck_mode_enum"),
|
||||
CheckConstraint("status IN ('success', 'failed', 'degraded')", name="ck_status_enum"),
|
||||
)
|
||||
|
||||
|
||||
class AppSetting(Base):
|
||||
"""后台管理的运行时设置(如数据源 API Key),读取时优先于 .env 默认值。"""
|
||||
__tablename__ = "app_settings"
|
||||
|
||||
key: Mapped[str] = mapped_column(String(100), primary_key=True)
|
||||
value: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
|
||||
|
||||
@@ -183,7 +183,7 @@ async def run_agent(
|
||||
user=user_prompt,
|
||||
json_mode=True,
|
||||
temperature=0.2,
|
||||
max_tokens=600,
|
||||
max_tokens=4096, # 推理模型需要更大余量
|
||||
)
|
||||
if resp.error:
|
||||
logger.warning("agent %s LLM failed: %s", spec.name, resp.error)
|
||||
|
||||
@@ -13,6 +13,7 @@ from src.core.config import settings
|
||||
from src.db.base import AsyncSessionLocal
|
||||
from src.db.models import Match, Prediction
|
||||
from src.db.unit_of_work import get_uow
|
||||
from src.llm.predict import _upsert_prediction
|
||||
from src.llm.agents.base import AgentReport, AgentSpec, load_agent_prompt
|
||||
from src.llm.context_builder import (
|
||||
MatchHeader,
|
||||
@@ -24,6 +25,7 @@ from src.llm.context_builder import (
|
||||
load_match_header,
|
||||
stats_slice,
|
||||
)
|
||||
from src.core.runtime_config import get_runtime_value
|
||||
from src.llm.provider import LLMProvider, get_default_provider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -58,7 +60,20 @@ SPECIALIST_SPECS: list[AgentSpec] = [
|
||||
),
|
||||
]
|
||||
|
||||
AGGREGATOR_SYSTEM = "你是足球预测终裁专家。综合各领域报告输出最终预测。只输出 JSON。"
|
||||
AGGREGATOR_SYSTEM = (
|
||||
"你是足球预测终裁专家。综合各领域专家报告输出最终预测。"
|
||||
"引用专家时必须使用报告中的专家全名(如「攻防数据分析专家」),禁止使用英文代码。"
|
||||
"只输出 JSON。"
|
||||
)
|
||||
|
||||
# 专家代码 → 终裁/展示统一称呼
|
||||
AGENT_LABELS_ZH: dict[str, str] = {
|
||||
"form": "近期状态分析专家",
|
||||
"stats": "攻防数据分析专家",
|
||||
"home_away": "主客因素分析专家",
|
||||
"injuries": "阵容完整性分析专家",
|
||||
"h2h": "历史交锋分析专家",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -70,6 +85,8 @@ class MultiPredictResult:
|
||||
mode: str
|
||||
pred_home_goals: float | None
|
||||
pred_away_goals: float | None
|
||||
alt_pred_home_goals: int | None
|
||||
alt_pred_away_goals: int | None
|
||||
pred_1x2: str | None
|
||||
subjective_confidence: float | None
|
||||
reasoning: str | None
|
||||
@@ -80,31 +97,38 @@ class MultiPredictResult:
|
||||
raw: dict | None
|
||||
|
||||
|
||||
def _get_specialist_provider() -> LLMProvider:
|
||||
"""专家模型: LLM_SPECIALIST_MODEL 回落 LLM_MODEL。"""
|
||||
p = get_default_provider()
|
||||
if settings.LLM_SPECIALIST_MODEL:
|
||||
p.model = settings.LLM_SPECIALIST_MODEL
|
||||
return p
|
||||
async def _agent_provider(agent_id: str, *, tier: str) -> LLMProvider:
|
||||
"""构造某 agent 专属 provider。
|
||||
|
||||
|
||||
def _get_aggregator_provider() -> LLMProvider:
|
||||
"""终裁模型: LLM_AGGREGATOR_MODEL 回落 LLM_MODEL。"""
|
||||
p = get_default_provider()
|
||||
if settings.LLM_AGGREGATOR_MODEL:
|
||||
p.model = settings.LLM_AGGREGATOR_MODEL
|
||||
覆盖优先级:
|
||||
模型: AGENT_MODEL_{ID}(运行时) → 层级默认(LLM_SPECIALIST/AGGREGATOR_MODEL) → 全局 LLM_MODEL
|
||||
地址/密钥: AGENT_BASE_URL_{ID} / AGENT_API_KEY_{ID}(运行时) → 全局 LLM_BASE_URL / LLM_API_KEY
|
||||
"""
|
||||
pfx = f"AGENT_{agent_id.upper()}_"
|
||||
p = await get_default_provider()
|
||||
tier_model = settings.LLM_SPECIALIST_MODEL if tier == "specialist" else settings.LLM_AGGREGATOR_MODEL
|
||||
if tier_model:
|
||||
p.model = tier_model
|
||||
model = await get_runtime_value(f"{pfx}MODEL")
|
||||
if model:
|
||||
p.model = model
|
||||
base = await get_runtime_value(f"{pfx}BASE_URL")
|
||||
if base:
|
||||
p.base_url = base
|
||||
key = await get_runtime_value(f"{pfx}API_KEY")
|
||||
if key:
|
||||
p.api_key = key
|
||||
return p
|
||||
|
||||
|
||||
async def run_specialists(
|
||||
header: MatchHeader,
|
||||
*,
|
||||
provider: LLMProvider,
|
||||
version: str = "v1",
|
||||
) -> list[AgentReport]:
|
||||
"""并行执行 5 个专家 agent。fail-open: 单个失败不影响其他。"""
|
||||
tasks = [
|
||||
_run_one(spec, header, provider, version=version)
|
||||
_run_one(spec, header, await _agent_provider(spec.name, tier="specialist"), version=version)
|
||||
for spec in SPECIALIST_SPECS
|
||||
]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
@@ -125,7 +149,13 @@ async def _run_one(spec, header, provider, *, version) -> AgentReport:
|
||||
|
||||
|
||||
def _reports_to_json(reports: list[AgentReport]) -> str:
|
||||
return json.dumps([r.to_dict() for r in reports], ensure_ascii=False, indent=1)
|
||||
"""报告序列化: agent 字段直接用中文专家全名,引导终裁用统一称呼引用。"""
|
||||
out = []
|
||||
for r in reports:
|
||||
d = r.to_dict()
|
||||
d["agent"] = AGENT_LABELS_ZH.get(d.get("agent", ""), d.get("agent"))
|
||||
out.append(d)
|
||||
return json.dumps(out, ensure_ascii=False, indent=1)
|
||||
|
||||
|
||||
async def run_aggregator(
|
||||
@@ -147,7 +177,7 @@ async def run_aggregator(
|
||||
user=user_prompt,
|
||||
json_mode=True,
|
||||
temperature=0.2,
|
||||
max_tokens=1000,
|
||||
max_tokens=4096, # 推理模型需要更大余量
|
||||
)
|
||||
if resp.error:
|
||||
raise RuntimeError(f"aggregator LLM error: {resp.error}")
|
||||
@@ -171,12 +201,11 @@ async def predict_match_multi(
|
||||
prediction_cutoff_at = header.match_dt # 默认:比赛时间作为数据截止
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# 2. 并行专家
|
||||
specialist_provider = _get_specialist_provider()
|
||||
reports = await run_specialists(header, provider=specialist_provider, version=version)
|
||||
# 2. 并行专家(各自独立配置)
|
||||
reports = await run_specialists(header, version=version)
|
||||
|
||||
# 3. 终裁
|
||||
aggregator_provider = _get_aggregator_provider()
|
||||
aggregator_provider = await _agent_provider("aggregator", tier="aggregator")
|
||||
final, agg_prompt_tokens, agg_completion_tokens = await run_aggregator(
|
||||
header, reports, provider=aggregator_provider, version=version
|
||||
)
|
||||
@@ -203,30 +232,33 @@ async def predict_match_multi(
|
||||
|
||||
# agent_weights 同样必须过校验(旧实现直接取 raw 值落库,未做任何检查)
|
||||
agent_weights = validate_agent_weights(final.get("agent_weights"))
|
||||
pred = Prediction(
|
||||
pred = await _upsert_prediction(
|
||||
session,
|
||||
match_id=match_id,
|
||||
provider=settings.LLM_PROVIDER,
|
||||
provider_name=settings.LLM_PROVIDER,
|
||||
model=aggregator_provider.model,
|
||||
prompt_version=f"multi_{version}",
|
||||
mode="multi",
|
||||
prompt_tokens=sum(r.prompt_tokens or 0 for r in reports) + agg_prompt_tokens,
|
||||
completion_tokens=sum(r.completion_tokens or 0 for r in reports) + agg_completion_tokens,
|
||||
latency_ms=latency_ms,
|
||||
pred_home_goals=validated.pred_home_goals,
|
||||
pred_away_goals=validated.pred_away_goals,
|
||||
pred_1x2=validated.pred_1x2,
|
||||
subjective_confidence=validated.subjective_confidence,
|
||||
reasoning=validated.reasoning,
|
||||
raw_response=final,
|
||||
agent_outputs=[r.to_dict() for r in reports],
|
||||
status="success",
|
||||
match_kickoff_at=match_kickoff_at,
|
||||
prediction_cutoff_at=prediction_cutoff_at,
|
||||
prediction_created_at=now,
|
||||
input_hash=input_hash,
|
||||
values={
|
||||
"prompt_version": f"multi_{version}",
|
||||
"prompt_tokens": sum(r.prompt_tokens or 0 for r in reports) + agg_prompt_tokens,
|
||||
"completion_tokens": sum(r.completion_tokens or 0 for r in reports) + agg_completion_tokens,
|
||||
"latency_ms": latency_ms,
|
||||
"pred_home_goals": validated.pred_home_goals,
|
||||
"pred_away_goals": validated.pred_away_goals,
|
||||
"alt_pred_home_goals": validated.alt_pred_home_goals,
|
||||
"alt_pred_away_goals": validated.alt_pred_away_goals,
|
||||
"pred_1x2": validated.pred_1x2,
|
||||
"subjective_confidence": validated.subjective_confidence,
|
||||
"reasoning": validated.reasoning,
|
||||
"raw_response": final,
|
||||
"agent_outputs": [r.to_dict() for r in reports],
|
||||
"status": "success",
|
||||
"match_kickoff_at": match_kickoff_at,
|
||||
"prediction_cutoff_at": prediction_cutoff_at,
|
||||
"prediction_created_at": now,
|
||||
"input_hash": input_hash,
|
||||
},
|
||||
)
|
||||
session.add(pred)
|
||||
await session.refresh(pred)
|
||||
|
||||
return MultiPredictResult(
|
||||
prediction_id=pred.id,
|
||||
@@ -236,6 +268,8 @@ async def predict_match_multi(
|
||||
mode="multi",
|
||||
pred_home_goals=pred.pred_home_goals,
|
||||
pred_away_goals=pred.pred_away_goals,
|
||||
alt_pred_home_goals=pred.alt_pred_home_goals,
|
||||
alt_pred_away_goals=pred.alt_pred_away_goals,
|
||||
pred_1x2=pred.pred_1x2,
|
||||
subjective_confidence=pred.subjective_confidence,
|
||||
reasoning=pred.reasoning,
|
||||
|
||||
@@ -20,6 +20,7 @@ from src.db.unit_of_work import get_uow
|
||||
from src.llm.eval import settle_prediction
|
||||
from src.llm.predict import predict_match
|
||||
from src.llm.utils import actual_1x2
|
||||
from src.data.team_names_zh import zh_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -31,6 +32,8 @@ class BacktestMatchResult:
|
||||
league_code: str | None
|
||||
home_team: str
|
||||
away_team: str
|
||||
home_team_zh: str | None
|
||||
away_team_zh: str | None
|
||||
match_date: str
|
||||
actual_home: int
|
||||
actual_away: int
|
||||
@@ -54,6 +57,8 @@ class BacktestCandidate:
|
||||
league_code: str | None
|
||||
home_team: str
|
||||
away_team: str
|
||||
home_team_zh: str | None
|
||||
away_team_zh: str | None
|
||||
match_date: datetime
|
||||
home_goals: int
|
||||
away_goals: int
|
||||
@@ -113,6 +118,8 @@ async def _get_historical_matches(
|
||||
league_code=m.league.code if m.league else None,
|
||||
home_team=m.home_team.name if m.home_team else "?",
|
||||
away_team=m.away_team.name if m.away_team else "?",
|
||||
home_team_zh=zh_name(m.home_team.name) if m.home_team else None,
|
||||
away_team_zh=zh_name(m.away_team.name) if m.away_team else None,
|
||||
match_date=m.match_date,
|
||||
home_goals=m.home_goals,
|
||||
away_goals=m.away_goals,
|
||||
@@ -164,6 +171,8 @@ async def run_backtest(
|
||||
league_code=c.league_code,
|
||||
home_team=c.home_team,
|
||||
away_team=c.away_team,
|
||||
home_team_zh=c.home_team_zh,
|
||||
away_team_zh=c.away_team_zh,
|
||||
match_date=c.match_date.strftime("%Y-%m-%d") if c.match_date else "?",
|
||||
actual_home=c.home_goals,
|
||||
actual_away=c.away_goals,
|
||||
|
||||
+68
-21
@@ -11,6 +11,8 @@ from pathlib import Path
|
||||
from threading import Lock
|
||||
|
||||
from src.core.config import settings
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.db.base import AsyncSessionLocal
|
||||
from src.db.models import Match, Prediction
|
||||
from src.db.unit_of_work import get_uow
|
||||
@@ -89,6 +91,8 @@ class PredictResult:
|
||||
prompt_version: str
|
||||
pred_home_goals: float | None
|
||||
pred_away_goals: float | None
|
||||
alt_pred_home_goals: int | None
|
||||
alt_pred_away_goals: int | None
|
||||
pred_1x2: str | None
|
||||
subjective_confidence: float | None
|
||||
reasoning: str | None
|
||||
@@ -97,6 +101,43 @@ class PredictResult:
|
||||
raw: dict | None
|
||||
|
||||
|
||||
async def _upsert_prediction(
|
||||
session,
|
||||
*,
|
||||
match_id: int,
|
||||
provider_name: str,
|
||||
model: str,
|
||||
mode: str,
|
||||
values: dict,
|
||||
) -> Prediction:
|
||||
"""按 (match, provider, model) 唯一约束写入预测。
|
||||
|
||||
已存在且未结算 → 覆盖更新(重新预测语义);已结算 → 拒绝(保护评估数据)。
|
||||
"""
|
||||
existing = (
|
||||
await session.execute(
|
||||
select(Prediction).where(
|
||||
Prediction.match_id == match_id,
|
||||
Prediction.provider == provider_name,
|
||||
Prediction.model == model,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing is not None and existing.settled:
|
||||
raise ValueError("该比赛已有已结算的预测,不能重新预测")
|
||||
|
||||
pred = existing if existing is not None else Prediction(
|
||||
match_id=match_id, provider=provider_name, model=model,
|
||||
)
|
||||
pred.mode = mode
|
||||
for k, v in values.items():
|
||||
setattr(pred, k, v)
|
||||
if existing is None:
|
||||
session.add(pred)
|
||||
await session.flush() # 拿到自增 id;事务由 UnitOfWork 退出时提交
|
||||
return pred
|
||||
|
||||
|
||||
async def predict_match(
|
||||
match_id: int,
|
||||
*,
|
||||
@@ -140,7 +181,7 @@ async def _predict_single(
|
||||
) -> PredictResult:
|
||||
"""单次调用路径(原有实现)。"""
|
||||
if provider is None:
|
||||
provider = get_default_provider()
|
||||
provider = await get_default_provider()
|
||||
if model:
|
||||
provider.model = model
|
||||
version = prompt_version or "v1"
|
||||
@@ -172,7 +213,7 @@ async def _predict_single(
|
||||
user=user_prompt,
|
||||
json_mode=True,
|
||||
temperature=0.3,
|
||||
max_tokens=800,
|
||||
max_tokens=4096, # 推理模型的 reasoning 也计入输出 token,需留足余量
|
||||
)
|
||||
|
||||
if resp.error:
|
||||
@@ -194,28 +235,32 @@ async def _predict_single(
|
||||
if m is None:
|
||||
raise ValueError(f"match {match_id} not found")
|
||||
|
||||
pred = Prediction(
|
||||
pred = await _upsert_prediction(
|
||||
session,
|
||||
match_id=match_id,
|
||||
provider=settings.LLM_PROVIDER,
|
||||
provider_name=settings.LLM_PROVIDER,
|
||||
model=provider.model,
|
||||
prompt_version=version,
|
||||
prompt_tokens=resp.prompt_tokens,
|
||||
completion_tokens=resp.completion_tokens,
|
||||
latency_ms=resp.latency_ms,
|
||||
pred_home_goals=validated.pred_home_goals,
|
||||
pred_away_goals=validated.pred_away_goals,
|
||||
pred_1x2=validated.pred_1x2,
|
||||
subjective_confidence=validated.subjective_confidence,
|
||||
reasoning=validated.reasoning,
|
||||
raw_response=resp.raw,
|
||||
status="success",
|
||||
match_kickoff_at=match_kickoff_at,
|
||||
prediction_cutoff_at=prediction_cutoff_at,
|
||||
prediction_created_at=now,
|
||||
input_hash=input_hash,
|
||||
mode="single",
|
||||
values={
|
||||
"prompt_version": version,
|
||||
"prompt_tokens": resp.prompt_tokens,
|
||||
"completion_tokens": resp.completion_tokens,
|
||||
"latency_ms": resp.latency_ms,
|
||||
"pred_home_goals": validated.pred_home_goals,
|
||||
"pred_away_goals": validated.pred_away_goals,
|
||||
"alt_pred_home_goals": validated.alt_pred_home_goals,
|
||||
"alt_pred_away_goals": validated.alt_pred_away_goals,
|
||||
"pred_1x2": validated.pred_1x2,
|
||||
"subjective_confidence": validated.subjective_confidence,
|
||||
"reasoning": validated.reasoning,
|
||||
"raw_response": resp.raw,
|
||||
"status": "success",
|
||||
"match_kickoff_at": match_kickoff_at,
|
||||
"prediction_cutoff_at": prediction_cutoff_at,
|
||||
"prediction_created_at": now,
|
||||
"input_hash": input_hash,
|
||||
},
|
||||
)
|
||||
session.add(pred)
|
||||
await session.refresh(pred)
|
||||
|
||||
result = PredictResult(
|
||||
prediction_id=pred.id,
|
||||
@@ -224,6 +269,8 @@ async def _predict_single(
|
||||
prompt_version=version,
|
||||
pred_home_goals=pred.pred_home_goals,
|
||||
pred_away_goals=pred.pred_away_goals,
|
||||
alt_pred_home_goals=pred.alt_pred_home_goals,
|
||||
alt_pred_away_goals=pred.alt_pred_away_goals,
|
||||
pred_1x2=pred.pred_1x2,
|
||||
subjective_confidence=pred.subjective_confidence,
|
||||
reasoning=pred.reasoning,
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
|
||||
裁决规则:
|
||||
- 各报告的 confidence 和 data_sufficiency 是采信依据: no_data/error 状态的报告必须忽略,不得编造
|
||||
- 5 个专家维度: form(近期状态) / stats(攻防数据) / home_away(主客因素) / injuries(阵容完整性) / h2h(历史交锋)
|
||||
- 5 位专家: 近期状态分析专家 / 攻防数据分析专家 / 主客因素分析专家 / 阵容完整性分析专家 / 历史交锋分析专家
|
||||
- 引用专家意见时使用上述全称,不要使用英文代码(form/stats/h2h 等)
|
||||
- home_edge 是各专家的方向性判断(-1~1),冲突时给出你的权衡理由
|
||||
- agent_weights 体现你对各报告的采信度(0-1,总和无须为 1)
|
||||
- reasoning 需引用具体报告的证据
|
||||
@@ -17,8 +18,10 @@
|
||||
严格按此 JSON 输出,不要其他内容:
|
||||
```json
|
||||
{
|
||||
"pred_home_goals": <float, 预测主队进球>,
|
||||
"pred_away_goals": <float, 预测客队进球>,
|
||||
"pred_home_goals": <int 0-10, 预测主队进球,必须是整数>,
|
||||
"pred_away_goals": <int 0-10, 预测客队进球,必须是整数>,
|
||||
"alt_pred_home_goals": <int 0-10, 备选比分主队进球(第二可能的比分,必须与主选不同)>,
|
||||
"alt_pred_away_goals": <int 0-10, 备选比分客队进球>,
|
||||
"1x2": "<'1'|'X'|'2'>",
|
||||
"confidence": <0.0-1.0>,
|
||||
"reasoning": "<250 字内推理,引用各报告证据>",
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
严格按此 JSON 输出:
|
||||
```json
|
||||
{
|
||||
"pred_home_goals": "<float, 预测主队进球>",
|
||||
"pred_away_goals": "<float, 预测客队进球>",
|
||||
"pred_home_goals": "<int 0-10, 预测主队进球,必须是整数>",
|
||||
"pred_away_goals": "<int 0-10, 预测客队进球,必须是整数>",
|
||||
"alt_pred_home_goals": "<int 0-10, 备选比分主队进球(第二可能的比分,必须与主选不同)>",
|
||||
"alt_pred_away_goals": "<int 0-10, 备选比分客队进球>",
|
||||
"1x2": "<'1'|'X'|'2'>",
|
||||
"confidence": "<0.0-1.0>",
|
||||
"score_probable": {"home": "<int>", "away": "<int>", "prob": "<float>"},
|
||||
|
||||
@@ -11,8 +11,10 @@
|
||||
严格按此 JSON 输出,不要其他内容:
|
||||
```json
|
||||
{
|
||||
"pred_home_goals": "<float, 预测主队进球>",
|
||||
"pred_away_goals": "<float, 预测客队进球>",
|
||||
"pred_home_goals": "<int 0-10, 预测主队进球,必须是整数>",
|
||||
"pred_away_goals": "<int 0-10, 预测客队进球,必须是整数>",
|
||||
"alt_pred_home_goals": "<int 0-10, 备选比分主队进球(第二可能的比分,必须与主选不同)>",
|
||||
"alt_pred_away_goals": "<int 0-10, 备选比分客队进球>",
|
||||
"1x2": "<'1'|'X'|'2'>",
|
||||
"confidence": "<0.0-1.0>",
|
||||
"score_probable": {"home": "<int>", "away": "<int>", "prob": "<float>"},
|
||||
|
||||
+19
-6
@@ -10,8 +10,11 @@ import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from src.core.config import settings
|
||||
from src.core.http_client import get_client
|
||||
from src.core.runtime_config import get_runtime_value
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -67,17 +70,26 @@ class LLMProvider:
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
client = get_client()
|
||||
# 连接与读取分离:端点不可达时 10s 内快速失败,
|
||||
# 避免每个 agent 各挂满 LLM_TIMEOUT 导致整次预测长时间无响应
|
||||
resp = await client.post(
|
||||
f"{self.base_url}/chat/completions",
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=self.timeout,
|
||||
timeout=httpx.Timeout(connect=10.0, read=float(self.timeout), write=float(self.timeout), pool=10.0),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
latency = int((time.perf_counter() - start) * 1000)
|
||||
usage = data.get("usage", {})
|
||||
content = data["choices"][0]["message"]["content"]
|
||||
message = data["choices"][0]["message"]
|
||||
content = message.get("content") or ""
|
||||
if not content:
|
||||
# 推理模型可能把 token 全花在 reasoning_content 上
|
||||
raise RuntimeError(
|
||||
"模型未返回文本内容"
|
||||
+ ("(token 花在推理上,请增大 max_tokens)" if message.get("reasoning_content") else "")
|
||||
)
|
||||
parsed = None
|
||||
if json_mode:
|
||||
try:
|
||||
@@ -105,10 +117,11 @@ class LLMProvider:
|
||||
return LLMResponse(content="", error=str(e), latency_ms=latency)
|
||||
|
||||
|
||||
def get_default_provider() -> LLMProvider:
|
||||
async def get_default_provider() -> LLMProvider:
|
||||
"""构造默认 provider:运行时配置(DB)优先,回落 .env。"""
|
||||
return LLMProvider(
|
||||
api_key=settings.LLM_API_KEY,
|
||||
base_url=settings.LLM_BASE_URL,
|
||||
model=settings.LLM_MODEL,
|
||||
api_key=await get_runtime_value("LLM_API_KEY"),
|
||||
base_url=await get_runtime_value("LLM_BASE_URL"),
|
||||
model=await get_runtime_value("LLM_MODEL"),
|
||||
timeout=settings.LLM_TIMEOUT,
|
||||
)
|
||||
|
||||
+32
-4
@@ -5,6 +5,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
@@ -54,12 +55,28 @@ class AgentReportSchema(BaseModel):
|
||||
class PredictionOutputSchema(BaseModel):
|
||||
"""最终预测输出的校验 schema。"""
|
||||
|
||||
pred_home_goals: float = Field(ge=0.0, le=10.0)
|
||||
pred_away_goals: float = Field(ge=0.0, le=10.0)
|
||||
pred_home_goals: int = Field(ge=0, le=10)
|
||||
pred_away_goals: int = Field(ge=0, le=10)
|
||||
# 备选比分(次可能比分);缺失/无效/与主选相同 → None
|
||||
alt_pred_home_goals: int | None = Field(default=None, ge=0, le=10)
|
||||
alt_pred_away_goals: int | None = Field(default=None, ge=0, le=10)
|
||||
pred_1x2: str
|
||||
subjective_confidence: float = Field(ge=0.0, le=1.0)
|
||||
reasoning: str = ""
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_alt_score(self) -> "PredictionOutputSchema":
|
||||
"""备选比分与主选相同则丢弃(备选必须是不同比分)。"""
|
||||
if (
|
||||
self.alt_pred_home_goals is not None
|
||||
and self.alt_pred_away_goals is not None
|
||||
and self.alt_pred_home_goals == self.pred_home_goals
|
||||
and self.alt_pred_away_goals == self.pred_away_goals
|
||||
):
|
||||
self.alt_pred_home_goals = None
|
||||
self.alt_pred_away_goals = None
|
||||
return self""
|
||||
|
||||
@field_validator("pred_1x2")
|
||||
@classmethod
|
||||
def validate_1x2(cls, v: str) -> str:
|
||||
@@ -172,9 +189,20 @@ def validate_prediction_output(raw: dict) -> PredictionOutputSchema:
|
||||
logger.warning("Deprecated field 'confidence' used, prefer 'subjective_confidence'")
|
||||
conf = raw["confidence"]
|
||||
|
||||
def _alt(side: str):
|
||||
v = raw.get(f"alt_pred_{side}_goals")
|
||||
if v is None:
|
||||
return None
|
||||
try:
|
||||
return int(Decimal(str(v)).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
return PredictionOutputSchema(
|
||||
pred_home_goals=float(raw.get("pred_home_goals", 0)),
|
||||
pred_away_goals=float(raw.get("pred_away_goals", 0)),
|
||||
pred_home_goals=int(Decimal(str(raw.get("pred_home_goals", 0))).quantize(Decimal("1"), rounding=ROUND_HALF_UP)),
|
||||
pred_away_goals=int(Decimal(str(raw.get("pred_away_goals", 0))).quantize(Decimal("1"), rounding=ROUND_HALF_UP)),
|
||||
alt_pred_home_goals=_alt("home"),
|
||||
alt_pred_away_goals=_alt("away"),
|
||||
pred_1x2=raw.get("1x2") or raw.get("pred_1x2", "X"),
|
||||
subjective_confidence=float(conf if conf is not None else 0.5),
|
||||
reasoning=str(raw.get("reasoning", ""))[:1000],
|
||||
|
||||
Reference in New Issue
Block a user