Compare commits
52
Commits
@@ -0,0 +1,47 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
.pytest_cache/
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
|
||||||
|
# AI 助手上下文文件(不入库)
|
||||||
|
CLAUDE.md
|
||||||
|
docs/AGENTS.md
|
||||||
|
docs/agents/
|
||||||
|
|
||||||
|
# 本地审查/预览脚手架(不入库)
|
||||||
|
.tools/
|
||||||
|
.preview/
|
||||||
|
|
||||||
|
# ===== ↑ 以上同步自 .gitignore(「从 .gitignore 同步」只重写以上部分)=====
|
||||||
|
.git/
|
||||||
|
.hg/
|
||||||
|
.svn/
|
||||||
|
node_modules/
|
||||||
|
bower_components/
|
||||||
|
jspm_packages/
|
||||||
|
site-packages/
|
||||||
|
venv/
|
||||||
|
coverage/
|
||||||
|
htmlcov/
|
||||||
|
lcov-report/
|
||||||
|
cmakefiles/
|
||||||
|
cmake-build-*/
|
||||||
|
bazel-*/
|
||||||
|
pods/
|
||||||
|
deriveddata/
|
||||||
|
storybook-static/
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
allure-results/
|
||||||
|
allure-report/
|
||||||
|
cdk.out/
|
||||||
|
*.egg-info/
|
||||||
|
*.dist-info/
|
||||||
|
eggs/
|
||||||
|
pip-wheel-metadata/
|
||||||
|
wheels/
|
||||||
|
# ----- ↑ 以上为 ZCode 默认排除规则(自定义规则请写在本行下方,不会被同步/恢复改动)-----
|
||||||
|
# 自定义规则写在下方(本行提示可删除)
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"""bzzoiro 单一数据源重构:新增 standings、扩展 match_stats、删除 injuries
|
||||||
|
|
||||||
|
Revision ID: 0015_bzzoiro_single_source
|
||||||
|
Revises: 0014_predictions_agent_weights
|
||||||
|
Create Date: 2026-09-20
|
||||||
|
|
||||||
|
变更内容:
|
||||||
|
1. 新增 standings 表(联赛积分榜快照,来源 bzzoiro /leagues/{id}/standings/)
|
||||||
|
2. match_stats 新增 bzzoiro /events/{id}/stats/ 扩展字段:
|
||||||
|
home/away_big_chances, home/away_fouls
|
||||||
|
3. 删除 injuries 表(数据源已下线,不再采集伤停)
|
||||||
|
4. Understat 无独立表(xG 写入 match_stats),无需删表;
|
||||||
|
历史 source='understat' 数据保留,不再新增。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '0015_bzzoiro_single_source'
|
||||||
|
down_revision: Union[str, None] = '0014_predictions_agent_weights'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# 0. matches 增加数据血缘列(bzzoiro 上游事件 ID)
|
||||||
|
op.add_column('matches', sa.Column('source_event_id', sa.BigInteger()))
|
||||||
|
op.create_index('ix_matches_source_event_id', 'matches', ['source_event_id'])
|
||||||
|
|
||||||
|
# 1. standings 表
|
||||||
|
op.create_table(
|
||||||
|
'standings',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True),
|
||||||
|
sa.Column('league_id', sa.Integer(), sa.ForeignKey('leagues.id'), nullable=False),
|
||||||
|
sa.Column('season', sa.String(12), nullable=False),
|
||||||
|
sa.Column('team_id', sa.Integer(), sa.ForeignKey('teams.id'), nullable=False),
|
||||||
|
sa.Column('position', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('played', sa.Integer(), nullable=False, server_default='0'),
|
||||||
|
sa.Column('won', sa.Integer(), nullable=False, server_default='0'),
|
||||||
|
sa.Column('drawn', sa.Integer(), nullable=False, server_default='0'),
|
||||||
|
sa.Column('lost', sa.Integer(), nullable=False, server_default='0'),
|
||||||
|
sa.Column('goals_for', sa.Integer(), nullable=False, server_default='0'),
|
||||||
|
sa.Column('goals_against', sa.Integer(), nullable=False, server_default='0'),
|
||||||
|
sa.Column('goal_diff', sa.Integer(), nullable=False, server_default='0'),
|
||||||
|
sa.Column('points', sa.Integer(), nullable=False, server_default='0'),
|
||||||
|
sa.Column('xg_for', sa.Float()),
|
||||||
|
sa.Column('xg_against', sa.Float()),
|
||||||
|
sa.Column('form', sa.String(20)),
|
||||||
|
sa.Column('zone', sa.String(50)),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True)),
|
||||||
|
sa.Column('retrieved_at', sa.DateTime(timezone=True)),
|
||||||
|
sa.UniqueConstraint('league_id', 'season', 'team_id', name='uq_standings_league_season_team'),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
'ix_standings_league_season_pos', 'standings',
|
||||||
|
['league_id', 'season', 'position'],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. match_stats 扩展字段
|
||||||
|
op.add_column('match_stats', sa.Column('home_big_chances', sa.Integer()))
|
||||||
|
op.add_column('match_stats', sa.Column('away_big_chances', sa.Integer()))
|
||||||
|
op.add_column('match_stats', sa.Column('home_fouls', sa.Integer()))
|
||||||
|
op.add_column('match_stats', sa.Column('away_fouls', sa.Integer()))
|
||||||
|
|
||||||
|
# 3. 删除 injuries 表
|
||||||
|
op.drop_index('ix_injuries_player_fixture', table_name='injuries')
|
||||||
|
op.drop_index('ix_injuries_team_date', table_name='injuries')
|
||||||
|
op.drop_table('injuries')
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# 恢复 injuries 表(不含数据)
|
||||||
|
op.create_table(
|
||||||
|
'injuries',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True),
|
||||||
|
sa.Column('player_id', sa.Integer()),
|
||||||
|
sa.Column('player_name', sa.String(120), nullable=False),
|
||||||
|
sa.Column('team_id', sa.Integer(), sa.ForeignKey('teams.id')),
|
||||||
|
sa.Column('fixture_id', sa.Integer()),
|
||||||
|
sa.Column('league_id', sa.Integer()),
|
||||||
|
sa.Column('injury_type', sa.String(50)),
|
||||||
|
sa.Column('reason', sa.String(200)),
|
||||||
|
sa.Column('injury_date', sa.Date()),
|
||||||
|
sa.Column('return_date', sa.Date()),
|
||||||
|
sa.Column('retrieved_at', sa.DateTime(timezone=True)),
|
||||||
|
)
|
||||||
|
op.create_index('ix_injuries_player_fixture', 'injuries', ['player_id', 'fixture_id', 'injury_type'])
|
||||||
|
op.create_index('ix_injuries_team_date', 'injuries', ['team_id', 'injury_date'])
|
||||||
|
|
||||||
|
op.drop_index('ix_matches_source_event_id', table_name='matches')
|
||||||
|
op.drop_column('matches', 'source_event_id')
|
||||||
|
|
||||||
|
op.drop_column('match_stats', 'away_fouls')
|
||||||
|
op.drop_column('match_stats', 'home_fouls')
|
||||||
|
op.drop_column('match_stats', 'away_big_chances')
|
||||||
|
op.drop_column('match_stats', 'home_big_chances')
|
||||||
|
|
||||||
|
op.drop_index('ix_standings_league_season_pos', table_name='standings')
|
||||||
|
op.drop_table('standings')
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""新增 schedules 表
|
||||||
|
|
||||||
|
Revision ID: 0016_schedules
|
||||||
|
Revises: 0015_bzzoiro_single_source
|
||||||
|
Create Date: 2026-09-21
|
||||||
|
|
||||||
|
定时采集任务配置表。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '0016_schedules'
|
||||||
|
down_revision: Union[str, None] = '0015_bzzoiro_single_source'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'schedules',
|
||||||
|
sa.Column('id', sa.String(50), primary_key=True),
|
||||||
|
sa.Column('task', sa.String(20), nullable=False),
|
||||||
|
sa.Column('cron', sa.String(100), nullable=False),
|
||||||
|
sa.Column('leagues', sa.Text()),
|
||||||
|
sa.Column('enabled', sa.Boolean(), server_default='true'),
|
||||||
|
sa.Column('last_run_at', sa.DateTime(timezone=True)),
|
||||||
|
sa.Column('last_status', sa.String(20)),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True)),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table('schedules')
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""扩展 ck_mode_enum 约束支持 baseline 模式
|
||||||
|
|
||||||
|
Revision ID: 0017_mode_baseline
|
||||||
|
Revises: 0016_schedules
|
||||||
|
Create Date: 2026-09-21
|
||||||
|
|
||||||
|
Code Review High-1/2 修复:
|
||||||
|
业务支持 mode='baseline'(非 LLM 统计基线),但 DB CHECK 约束只允许
|
||||||
|
('single', 'multi'),导致 baseline 预测写入时 CheckViolation。
|
||||||
|
run_type='baseline' 改为 'live'(代码侧),约束无需扩展。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '0017_mode_baseline'
|
||||||
|
down_revision: Union[str, None] = '0016_schedules'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.drop_constraint('ck_mode_enum', 'predictions', type_='check')
|
||||||
|
op.create_check_constraint(
|
||||||
|
'ck_mode_enum', 'predictions',
|
||||||
|
"mode IN ('single', 'multi', 'baseline')",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# 先清理 baseline 数据再恢复旧约束
|
||||||
|
op.execute("DELETE FROM predictions WHERE mode = 'baseline'")
|
||||||
|
op.drop_constraint('ck_mode_enum', 'predictions', type_='check')
|
||||||
|
op.create_check_constraint(
|
||||||
|
'ck_mode_enum', 'predictions',
|
||||||
|
"mode IN ('single', 'multi')",
|
||||||
|
)
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Match 表补充 CHECK 约束:完赛必须有比分 + 状态枚举 + 半场≤全场
|
||||||
|
|
||||||
|
Revision ID: 0018_match_checks
|
||||||
|
Revises: 0017_mode_baseline
|
||||||
|
Create Date: 2026-09-21
|
||||||
|
|
||||||
|
Code Review DB-5:
|
||||||
|
- 已完赛比赛必须有比分(数据库级兜底)
|
||||||
|
- match_status 枚举约束
|
||||||
|
- 半场进球 ≤ 全场进球
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '0018_match_checks'
|
||||||
|
down_revision: Union[str, None] = '0017_mode_baseline'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# 先清理可能违反新约束的数据
|
||||||
|
op.execute("UPDATE matches SET match_status = 'scheduled' WHERE match_status NOT IN ('finished', 'scheduled', 'in_play', 'paused', 'postponed', 'cancelled', 'suspended')")
|
||||||
|
op.execute("UPDATE matches SET home_goals = 0, away_goals = 0 WHERE match_status = 'finished' AND (home_goals IS NULL OR away_goals IS NULL)")
|
||||||
|
|
||||||
|
# 添加 CHECK 约束
|
||||||
|
op.create_check_constraint(
|
||||||
|
'ck_matches_finished_has_score', 'matches',
|
||||||
|
"match_status <> 'finished' OR (home_goals IS NOT NULL AND away_goals IS NOT NULL)",
|
||||||
|
)
|
||||||
|
op.create_check_constraint(
|
||||||
|
'ck_matches_status_enum', 'matches',
|
||||||
|
"match_status IN ('finished', 'scheduled', 'in_play', 'paused', 'postponed', 'cancelled', 'suspended')",
|
||||||
|
)
|
||||||
|
op.create_check_constraint(
|
||||||
|
'ck_matches_home_ht_le_full', 'matches',
|
||||||
|
"home_ht_goals IS NULL OR home_goals IS NULL OR home_ht_goals <= home_goals",
|
||||||
|
)
|
||||||
|
op.create_check_constraint(
|
||||||
|
'ck_matches_away_ht_le_full', 'matches',
|
||||||
|
"away_ht_goals IS NULL OR away_goals IS NULL OR away_ht_goals <= away_goals",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_constraint('ck_matches_away_ht_le_full', 'matches', type_='check')
|
||||||
|
op.drop_constraint('ck_matches_home_ht_le_full', 'matches', type_='check')
|
||||||
|
op.drop_constraint('ck_matches_status_enum', 'matches', type_='check')
|
||||||
|
op.drop_constraint('ck_matches_finished_has_score', 'matches', type_='check')
|
||||||
+60
-42
@@ -1,16 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* 主应用入口
|
* 主应用入口
|
||||||
*
|
*
|
||||||
* 顶层三分区导航:
|
* 页面结构:
|
||||||
* - 比赛/预测 → 公开,报纸风赛程 + 预测
|
* - / → 先知主站(报纸风赛程 + 预测),报头含「评估」「管理」入口
|
||||||
* - 评估 → 只读(后端需 admin 鉴权,未登录引导登录)
|
* - /admin/* → 管理后台(鉴权门禁,未登录引导登录)
|
||||||
* - 管理 → 采集/回测/配置等(需登录)
|
*
|
||||||
|
* 导航策略:
|
||||||
|
* - 首页报头放「评估」「管理」入口(极简,不另加导航条)
|
||||||
|
* - 管理后台由 AdminLayout 侧边栏处理所有管理页导航
|
||||||
|
* - 未登录访问管理 → AdminLayout 门禁 → 登录页(不静默失败)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
import { BrowserRouter, Routes, Route, Navigate, Link } from 'react-router-dom'
|
||||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||||
import { useState } from 'react'
|
|
||||||
import Matches from './pages/Matches'
|
import Matches from './pages/Matches'
|
||||||
|
import Standings from './pages/Standings'
|
||||||
import { adminRoutes } from './admin/routes'
|
import { adminRoutes } from './admin/routes'
|
||||||
|
|
||||||
/** 报眉日期行 */
|
/** 报眉日期行 */
|
||||||
@@ -23,51 +27,71 @@ function dateLine(): string {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 顶层导航三分区 */
|
function StandingsLayout({ children }: { children: React.ReactNode }) {
|
||||||
type TopView = 'matches' | 'eval' | 'admin'
|
|
||||||
|
|
||||||
function TopNav({ onNavigate }: { onNavigate: (v: TopView) => void }) {
|
|
||||||
const go = (v: TopView) => {
|
|
||||||
onNavigate(v)
|
|
||||||
const path = v === 'matches' ? '/' : v === 'eval' ? '/admin/eval' : '/admin'
|
|
||||||
window.location.assign(path)
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<nav className="flex items-center justify-center gap-1 border-b border-ink-200" aria-label="主导航">
|
<div className="min-h-screen bg-paper-50">
|
||||||
<button onClick={() => go('matches')} className="tab">
|
<header className="masthead-rule">
|
||||||
<span aria-hidden="true">◇</span> 比赛 / 预测
|
<div className="mx-auto max-w-5xl px-5 sm:px-8">
|
||||||
</button>
|
<div className="border-b border-ink-900 py-5 text-center sm:py-6">
|
||||||
<button onClick={() => go('eval')} className="tab">
|
<h1 className="font-brush text-5xl text-ink-900 sm:text-6xl">
|
||||||
<span aria-hidden="true">◈</span> 评估
|
先知
|
||||||
</button>
|
</h1>
|
||||||
<button onClick={() => go('admin')} className="tab">
|
</div>
|
||||||
<span aria-hidden="true">⚙</span> 管理
|
<div className="flex items-center justify-between border-b border-ink-200 py-2 text-2xs text-ink-500">
|
||||||
</button>
|
<span>{dateLine()}</span>
|
||||||
</nav>
|
<nav className="flex items-center gap-4" aria-label="页面导航">
|
||||||
|
<Link to="/" className="text-ink-500 hover:text-press transition-colors">
|
||||||
|
比赛 / 预测
|
||||||
|
</Link>
|
||||||
|
<Link to="/admin/eval" className="text-ink-500 hover:text-press transition-colors">
|
||||||
|
评估
|
||||||
|
</Link>
|
||||||
|
<Link to="/admin" className="flex items-center gap-1 text-ink-500 hover:text-press transition-colors">
|
||||||
|
<span aria-hidden="true">⚙</span> 管理
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto max-w-5xl px-5 py-6 sm:px-8 sm:py-8">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer className="mx-auto max-w-5xl px-5 pb-10 sm:px-8">
|
||||||
|
<div className="border-t border-ink-200 pt-3 text-center text-2xs leading-relaxed text-ink-400">
|
||||||
|
数据由 bzzoiro 提供 · 仅供研究参考
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function HomePage() {
|
function HomePage() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-paper-50">
|
<div className="min-h-screen bg-paper-50">
|
||||||
{/* ── 报头:粗线 + 居中刊名 + 顶层导航 ── */}
|
{/* ── 报头:粗线 + 居中刊名 + 日期与分区链接 ── */}
|
||||||
<header className="masthead-rule">
|
<header className="masthead-rule">
|
||||||
<div className="mx-auto max-w-5xl px-5 sm:px-8">
|
<div className="mx-auto max-w-5xl px-5 sm:px-8">
|
||||||
<div className="border-b border-ink-900 py-5 text-center sm:py-6">
|
<div className="border-b border-ink-900 py-5 text-center sm:py-6">
|
||||||
<h1 className="font-serif text-4xl font-bold tracking-widest text-ink-900">
|
<h1 className="font-brush text-5xl text-ink-900 sm:text-6xl">
|
||||||
先知
|
先知
|
||||||
<span className="ml-3 align-baseline font-serif text-base font-normal italic tracking-normal text-ink-500">
|
|
||||||
Profeto
|
|
||||||
</span>
|
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-2xs tracking-[0.4em] text-ink-500">
|
|
||||||
足球比分预测 · 五路专家 · 终裁汇总
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between border-b border-ink-200 py-2 text-2xs text-ink-500">
|
<div className="flex items-center justify-between border-b border-ink-200 py-2 text-2xs text-ink-500">
|
||||||
<span>{dateLine()}</span>
|
<span>{dateLine()}</span>
|
||||||
|
<nav className="flex items-center gap-4" aria-label="页面导航">
|
||||||
|
<Link to="/standings" className="text-ink-500 hover:text-press transition-colors">
|
||||||
|
积分榜
|
||||||
|
</Link>
|
||||||
|
<Link to="/admin/eval" className="text-ink-500 hover:text-press transition-colors">
|
||||||
|
评估
|
||||||
|
</Link>
|
||||||
|
<Link to="/admin" className="flex items-center gap-1 text-ink-500 hover:text-press transition-colors">
|
||||||
|
<span aria-hidden="true">⚙</span> 管理
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<TopNav onNavigate={() => {}} />
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -84,18 +108,13 @@ function HomePage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 管理入口页:直接导向 /admin,由 AdminLayout 处理鉴权(未登录显示登录页) */
|
|
||||||
function AdminEntry() {
|
|
||||||
return <Navigate to="/admin" replace />
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
<Route path="/admin" element={<AdminEntry />} />
|
<Route path="/standings" element={<StandingsLayout><Standings /></StandingsLayout>} />
|
||||||
{adminRoutes.map(route => (
|
{adminRoutes.map(route => (
|
||||||
<Route key={route.path} path={route.path} element={route.element}>
|
<Route key={route.path} path={route.path} element={route.element}>
|
||||||
{route.children.map(child => (
|
{route.children.map(child => (
|
||||||
@@ -114,4 +133,3 @@ export default function App() {
|
|||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+241
-105
@@ -10,20 +10,135 @@ import { NavLink, Outlet, useLocation } from 'react-router-dom'
|
|||||||
import { fetchAuthState, logout, UNAUTHORIZED_EVENT } from './api'
|
import { fetchAuthState, logout, UNAUTHORIZED_EVENT } from './api'
|
||||||
import { fetchHealth } from './dal'
|
import { fetchHealth } from './dal'
|
||||||
import Login from './Login'
|
import Login from './Login'
|
||||||
|
import { useCommandPalette, CommandPalette } from './useCommandPalette'
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
// 线条风格 SVG 图标组件
|
||||||
// ── 观测(只读) ──
|
function Icon({ name }: { name: string }) {
|
||||||
{ to: '/admin', label: '仪表盘', icon: '◇', end: true },
|
const common = 'nav-icon'
|
||||||
{ to: '/admin/eval', label: '评估', icon: '◈' },
|
switch (name) {
|
||||||
{ to: '/admin/monitoring', label: '监控', icon: '◐' },
|
case 'collection':
|
||||||
{ to: '/admin/logs', label: '日志', icon: '▤' },
|
return (
|
||||||
// ── 操作(写入,需登录) ──
|
<svg className={common} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
|
||||||
{ to: '/admin/predictions', label: '预测管理', icon: '◆' },
|
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
|
||||||
{ to: '/admin/collection', label: '数据采集', icon: '◈' },
|
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
|
||||||
{ to: '/admin/backtest', label: '回测', icon: '◉' },
|
<line x1="12" y1="22.08" x2="12" y2="12" />
|
||||||
{ to: '/admin/data-sources', label: '数据源', icon: '◫' },
|
</svg>
|
||||||
{ to: '/admin/llm-config', label: 'LLM 配置', icon: '◬' },
|
)
|
||||||
{ to: '/admin/config', label: '系统配置', icon: '◑' },
|
case 'chart':
|
||||||
|
return (
|
||||||
|
<svg className={common} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<line x1="18" y1="20" x2="18" y2="10" />
|
||||||
|
<line x1="12" y1="20" x2="12" y2="4" />
|
||||||
|
<line x1="6" y1="20" x2="6" y2="14" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'target':
|
||||||
|
return (
|
||||||
|
<svg className={common} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<circle cx="12" cy="12" r="6" />
|
||||||
|
<circle cx="12" cy="12" r="2" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'repeat':
|
||||||
|
return (
|
||||||
|
<svg className={common} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="17 1 21 5 17 9" />
|
||||||
|
<path d="M3 11V9a4 4 0 0 1 4-4h14" />
|
||||||
|
<polyline points="7 23 3 19 7 15" />
|
||||||
|
<path d="M21 13v2a4 4 0 0 1-4 4H3" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'eval':
|
||||||
|
return (
|
||||||
|
<svg className={common} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||||
|
<polyline points="14 2 14 8 20 8" />
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13" />
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17" />
|
||||||
|
<polyline points="10 9 9 9 8 9" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'monitor':
|
||||||
|
return (
|
||||||
|
<svg className={common} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
|
||||||
|
<line x1="8" y1="21" x2="16" y2="21" />
|
||||||
|
<line x1="12" y1="17" x2="12" y2="21" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'settings':
|
||||||
|
return (
|
||||||
|
<svg className={common} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68 1.65 1.65 0 0 0 10 3.17V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0-4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'logs':
|
||||||
|
return (
|
||||||
|
<svg className={common} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||||
|
<polyline points="14 2 14 8 20 8" />
|
||||||
|
<line x1="8" y1="13" x2="16" y2="13" />
|
||||||
|
<line x1="8" y1="17" x2="16" y2="17" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const NAV_PAGES: Array<{ to: string; label: string; group: string }> = [
|
||||||
|
{ to: '/admin', label: '仪表盘', group: '概览' },
|
||||||
|
{ to: '/admin/collection', label: '数据采集', group: '数据流水线' },
|
||||||
|
{ to: '/admin/data-completeness', label: '数据完整性', group: '数据流水线' },
|
||||||
|
{ to: '/admin/data-pipeline', label: '数据管线', group: '数据流水线' },
|
||||||
|
{ to: '/admin/predictions', label: '预测历史', group: '数据流水线' },
|
||||||
|
{ to: '/admin/backtest', label: '回测', group: '数据流水线' },
|
||||||
|
{ to: '/admin/monitoring', label: '监控', group: '评估与监控' },
|
||||||
|
{ to: '/admin/eval', label: '评估', group: '评估与监控' },
|
||||||
|
{ to: '/admin/settings', label: '设置', group: '系统' },
|
||||||
|
{ to: '/admin/logs', label: '日志', group: '系统' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// 路由 → 面包屑标签
|
||||||
|
const ROUTE_LABELS: Record<string, string> = {
|
||||||
|
'/admin': '仪表盘',
|
||||||
|
'/admin/collection': '数据采集',
|
||||||
|
'/admin/data-completeness': '数据完整性',
|
||||||
|
'/admin/data-pipeline': '数据管线',
|
||||||
|
'/admin/predictions': '预测历史',
|
||||||
|
'/admin/backtest': '回测',
|
||||||
|
'/admin/monitoring': '监控',
|
||||||
|
'/admin/settings': '设置',
|
||||||
|
'/admin/logs': '日志',
|
||||||
|
'/admin/eval': '评估',
|
||||||
|
}
|
||||||
|
|
||||||
|
const NAV_SECTIONS: { title: string; items: Array<{ to: string; label: string; icon: string }> }[] = [
|
||||||
|
{
|
||||||
|
title: '数据流水线',
|
||||||
|
items: [
|
||||||
|
{ to: '/admin/collection', label: '数据采集', icon: 'collection' },
|
||||||
|
{ to: '/admin/data-completeness', label: '数据完整性', icon: 'chart' },
|
||||||
|
{ to: '/admin/predictions', label: '预测历史', icon: 'logs' },
|
||||||
|
{ to: '/admin/backtest', label: '回测', icon: 'repeat' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '评估与监控',
|
||||||
|
items: [
|
||||||
|
{ to: '/admin/eval', label: '评估', icon: 'eval' },
|
||||||
|
{ to: '/admin/monitoring', label: '监控', icon: 'monitor' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '系统设置',
|
||||||
|
items: [
|
||||||
|
{ to: '/admin/settings', label: '设置', icon: 'settings' },
|
||||||
|
{ to: '/admin/logs', label: '日志', icon: 'logs' },
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
/** 报眉日期行,与前台同款式 */
|
/** 报眉日期行,与前台同款式 */
|
||||||
@@ -41,6 +156,7 @@ export default function AdminLayout() {
|
|||||||
const [healthOk, setHealthOk] = useState<boolean | null>(null)
|
const [healthOk, setHealthOk] = useState<boolean | null>(null)
|
||||||
const [authed, setAuthed] = useState<boolean | null>(null)
|
const [authed, setAuthed] = useState<boolean | null>(null)
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
const palette = useCommandPalette(NAV_PAGES)
|
||||||
|
|
||||||
// 登录门禁:挂载时探测会话,收到 401 事件(会话过期)自动切回登录页
|
// 登录门禁:挂载时探测会话,收到 401 事件(会话过期)自动切回登录页
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -108,7 +224,55 @@ export default function AdminLayout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen overflow-hidden bg-paper-50 text-ink-800">
|
<div className="flex h-screen flex-col overflow-hidden bg-paper-50 text-ink-800">
|
||||||
|
{/* ── 通栏顶部报眉 ── */}
|
||||||
|
<header className="flex h-11 flex-shrink-0 items-center justify-between border-b border-ink-200 bg-paper-50 px-4 lg:px-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* 移动端汉堡按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={() => setSidebarOpen(true)}
|
||||||
|
className="-ml-1 p-2 text-ink-500 hover:text-ink-900 lg:hidden"
|
||||||
|
aria-label="打开菜单"
|
||||||
|
>
|
||||||
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/* 面包屑 */}
|
||||||
|
<nav className="hidden items-center gap-1.5 text-2xs text-ink-400 sm:flex" aria-label="面包屑">
|
||||||
|
<a href="/admin" className="hover:text-ink-700">仪表盘</a>
|
||||||
|
{location.pathname !== '/admin' && (
|
||||||
|
<>
|
||||||
|
<span aria-hidden="true">/</span>
|
||||||
|
<span className="text-ink-600">{ROUTE_LABELS[location.pathname] ?? '未知页面'}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
<span className="hidden text-2xs text-ink-500 sm:inline">{dateLine()}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 text-2xs">
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-ink-500">
|
||||||
|
<span
|
||||||
|
className={`inline-block h-1.5 w-1.5 ${
|
||||||
|
healthOk === null ? 'bg-ink-300' : healthOk ? 'bg-ink-900' : 'bg-press'
|
||||||
|
}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{healthOk === null ? '检测中' : healthOk ? '系统正常' : '系统异常'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="text-ink-500 transition-colors hover:text-press"
|
||||||
|
title="退出登录"
|
||||||
|
>
|
||||||
|
登出
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* ── 主体:侧边栏 + 内容 ── */}
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
{/* ── 移动端遮罩层 ── */}
|
{/* ── 移动端遮罩层 ── */}
|
||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
<div
|
<div
|
||||||
@@ -128,114 +292,86 @@ export default function AdminLayout() {
|
|||||||
`}
|
`}
|
||||||
aria-label="主导航"
|
aria-label="主导航"
|
||||||
>
|
>
|
||||||
{/* 报头 */}
|
{/* 侧栏顶部 */}
|
||||||
<div className="flex items-center justify-between border-b border-ink-900 px-5 py-4">
|
<div className="flex items-center border-b border-ink-900 px-5 py-4">
|
||||||
<h1 className="font-serif text-lg font-bold tracking-widest text-ink-900">
|
<h1 className="font-brush text-xl text-ink-900">先知</h1>
|
||||||
先知
|
|
||||||
<span className="ml-2 align-baseline font-serif text-xs font-normal italic tracking-normal text-ink-500">
|
|
||||||
Profeto
|
|
||||||
</span>
|
|
||||||
</h1>
|
|
||||||
<span className="text-2xs tracking-[0.25em] text-ink-400">ADMIN</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 导航:选中项以印报红方块标记,同前台胜平负选中样式 */}
|
{/* 导航:分组 + 小节标题 */}
|
||||||
<nav className="flex-1 overflow-y-auto px-3 py-4" aria-label="管理导航">
|
<nav className="flex-1 overflow-y-auto px-3 py-3" aria-label="管理导航">
|
||||||
<ul className="space-y-0.5">
|
{/* 仪表盘独立(始终第一项) */}
|
||||||
{NAV_ITEMS.map(item => (
|
<NavLink
|
||||||
<li key={item.to}>
|
to="/admin"
|
||||||
<NavLink
|
end
|
||||||
to={item.to}
|
className={({ isActive }) =>
|
||||||
end={item.end}
|
`nav-icon-wrap flex min-h-[40px] items-center gap-2.5 border-l-4 px-3 text-sm transition-all duration-300 ${
|
||||||
className={({ isActive }) =>
|
isActive
|
||||||
`flex min-h-[44px] items-center gap-2.5 px-3 py-2.5 text-sm transition-colors ${
|
? 'border-press bg-press-wash/60 font-medium text-press active'
|
||||||
isActive
|
: 'border-transparent text-ink-500 hover:bg-paper-100 hover:text-ink-900'
|
||||||
? 'bg-press-wash/60 font-medium text-press'
|
}`
|
||||||
: 'text-ink-500 hover:bg-paper-100 hover:text-ink-900'
|
}
|
||||||
}`
|
>
|
||||||
}
|
<Icon name="chart" />
|
||||||
>
|
仪表盘
|
||||||
{({ isActive }) => (
|
</NavLink>
|
||||||
<>
|
|
||||||
<span
|
{NAV_SECTIONS.map(section => (
|
||||||
className={`inline-block h-1.5 w-1.5 flex-shrink-0 ${isActive ? 'bg-press' : 'bg-transparent'}`}
|
<div key={section.title} className="mt-4">
|
||||||
aria-hidden="true"
|
<p className="mb-1 px-3 text-2xs font-medium uppercase tracking-widest text-ink-400">
|
||||||
/>
|
{section.title}
|
||||||
<span className="text-base leading-none opacity-50" aria-hidden="true">
|
</p>
|
||||||
{item.icon}
|
<ul className="space-y-0.5">
|
||||||
</span>
|
{section.items.map(item => (
|
||||||
|
<li key={item.to}>
|
||||||
|
<NavLink
|
||||||
|
to={item.to}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`nav-icon-wrap flex min-h-[40px] items-center gap-2.5 border-l-4 px-3 text-sm transition-all duration-300 ${
|
||||||
|
isActive
|
||||||
|
? 'border-press bg-press-wash/60 font-medium text-press active'
|
||||||
|
: 'border-transparent text-ink-500 hover:bg-paper-100 hover:text-ink-900'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon name={item.icon} />
|
||||||
{item.label}
|
{item.label}
|
||||||
</>
|
</NavLink>
|
||||||
)}
|
</li>
|
||||||
</NavLink>
|
))}
|
||||||
</li>
|
</ul>
|
||||||
))}
|
</div>
|
||||||
</ul>
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* 底部 */}
|
{/* 底部 */}
|
||||||
<div className="border-t border-ink-200 px-4 py-3">
|
<div className="border-t border-ink-200 px-4 py-3 space-y-2">
|
||||||
<a
|
<a
|
||||||
href="/"
|
href="/"
|
||||||
className="flex min-h-[44px] items-center gap-2 text-xs text-ink-500 transition-colors hover:text-press"
|
className="flex min-h-[36px] items-center gap-2 text-xs text-ink-500 transition-colors hover:text-press"
|
||||||
>
|
>
|
||||||
<span aria-hidden="true">←</span>
|
<span aria-hidden="true">←</span>
|
||||||
返回前台版面
|
返回前台版面
|
||||||
</a>
|
</a>
|
||||||
|
<p className="text-2xs text-ink-300">v1.0</p>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* ── 主内容区 ── */}
|
{/* ── 主内容区 ── */}
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<main className="flex-1 overflow-y-auto p-4 lg:p-8" key={location.pathname}>
|
||||||
{/* 报眉:日期 + 系统状态 */}
|
<div className="mx-auto max-w-6xl page-content-enter">
|
||||||
<header className="flex h-11 flex-shrink-0 items-center justify-between border-b border-ink-200 px-4 lg:px-6">
|
<Outlet />
|
||||||
<div className="flex items-center gap-4">
|
</div>
|
||||||
<button
|
</main>
|
||||||
onClick={() => setSidebarOpen(true)}
|
|
||||||
className="-ml-1 p-2 text-ink-500 hover:text-ink-900 lg:hidden"
|
|
||||||
aria-label="打开菜单"
|
|
||||||
>
|
|
||||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<span className="hidden text-2xs text-ink-500 sm:inline">{dateLine()}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-4 text-2xs">
|
|
||||||
<span className="inline-flex items-center gap-1.5 text-ink-500">
|
|
||||||
<span
|
|
||||||
className={`inline-block h-1.5 w-1.5 ${
|
|
||||||
healthOk === null ? 'bg-ink-300' : healthOk ? 'bg-ink-900' : 'bg-press'
|
|
||||||
}`}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
{healthOk === null ? '检测中' : healthOk ? '系统正常' : '系统异常'}
|
|
||||||
</span>
|
|
||||||
<a
|
|
||||||
href="/"
|
|
||||||
className="text-press transition-colors hover:text-press-dark sm:hidden"
|
|
||||||
aria-label="返回前台"
|
|
||||||
>
|
|
||||||
前台
|
|
||||||
</a>
|
|
||||||
<button
|
|
||||||
onClick={handleLogout}
|
|
||||||
className="text-ink-500 transition-colors hover:text-press"
|
|
||||||
title="退出登录"
|
|
||||||
>
|
|
||||||
登出
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* 页面内容 */}
|
|
||||||
<main className="flex-1 overflow-y-auto p-4 lg:p-8">
|
|
||||||
<div className="mx-auto max-w-6xl">
|
|
||||||
<Outlet />
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 命令面板(⌘K) */}
|
||||||
|
<CommandPalette
|
||||||
|
open={palette.open}
|
||||||
|
query={palette.query}
|
||||||
|
setQuery={palette.setQuery}
|
||||||
|
items={palette.items}
|
||||||
|
onClose={() => palette.setOpen(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,14 +49,17 @@ async function request<T>(
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let detail: unknown
|
// 先读 text 再尝试 JSON 解析:Response body 流只能读一次,
|
||||||
|
// 若先调 res.json() 失败(如返回 HTML 错误页),再调 res.text() 会抛 "body stream already read"。
|
||||||
|
const rawText = await res.text()
|
||||||
|
let detail: unknown = rawText
|
||||||
try {
|
try {
|
||||||
detail = await res.json()
|
detail = JSON.parse(rawText)
|
||||||
} catch {
|
} catch {
|
||||||
detail = await res.text()
|
// 非 JSON(如 HTML 错误页),保留原始文本
|
||||||
}
|
}
|
||||||
let message =
|
let message =
|
||||||
detail && typeof detail === 'object' && 'detail' in detail
|
detail && typeof detail === 'object' && detail !== null && 'detail' in detail
|
||||||
? String((detail as { detail: unknown }).detail)
|
? String((detail as { detail: unknown }).detail)
|
||||||
: `HTTP ${res.status}: ${res.statusText}`
|
: `HTTP ${res.status}: ${res.statusText}`
|
||||||
// 401 仅在没有显式跳过时广播未登录事件(改密接口的 401 表示当前密码错误,非会话过期)
|
// 401 仅在没有显式跳过时广播未登录事件(改密接口的 401 表示当前密码错误,非会话过期)
|
||||||
|
|||||||
@@ -166,12 +166,12 @@ export function DataTable<T = any>({
|
|||||||
|
|
||||||
// ── 进度条:同前台置信度细线 ────────────────────────────────────
|
// ── 进度条:同前台置信度细线 ────────────────────────────────────
|
||||||
|
|
||||||
export function ProgressBar({ value }: { value: number }) {
|
export function ProgressBar({ value, className = '' }: { value: number; className?: string }) {
|
||||||
const clamped = Math.max(0, Math.min(100, value))
|
const clamped = Math.max(0, Math.min(100, value))
|
||||||
return (
|
return (
|
||||||
<div className="h-px w-full bg-ink-200" role="progressbar" aria-valuenow={clamped}>
|
<div className={`h-2 w-full overflow-hidden rounded-full bg-ink-200 ${className}`} role="progressbar" aria-valuenow={clamped}>
|
||||||
<div
|
<div
|
||||||
className="h-px bg-press transition-[width] duration-500"
|
className="h-full rounded-full bg-press transition-[width] duration-500"
|
||||||
style={{ width: `${clamped}%` }}
|
style={{ width: `${clamped}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -253,14 +253,19 @@ export function ResponsiveTable<T = any>({
|
|||||||
export function SectionHeader({
|
export function SectionHeader({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
|
action,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
description?: string
|
description?: string
|
||||||
|
action?: ReactNode
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-5">
|
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
|
||||||
<h2 className="section-head text-base">{title}</h2>
|
<div>
|
||||||
{description && <p className="mt-1.5 text-xs text-ink-500">{description}</p>}
|
<h2 className="section-head text-base">{title}</h2>
|
||||||
|
{description && <p className="mt-1.5 text-xs text-ink-500">{description}</p>}
|
||||||
|
</div>
|
||||||
|
{action}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -272,11 +277,14 @@ export function Alert({
|
|||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
onClose,
|
onClose,
|
||||||
|
action,
|
||||||
}: {
|
}: {
|
||||||
kind: 'error' | 'ok' | 'info' | 'warning'
|
kind: 'error' | 'ok' | 'info' | 'warning'
|
||||||
title: string
|
title: string
|
||||||
message?: string
|
message?: string
|
||||||
onClose?: () => void
|
onClose?: () => void
|
||||||
|
/** 右侧操作按钮(如「去修复」) */
|
||||||
|
action?: ReactNode
|
||||||
}) {
|
}) {
|
||||||
const style =
|
const style =
|
||||||
kind === 'error'
|
kind === 'error'
|
||||||
@@ -304,17 +312,20 @@ export function Alert({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{onClose && (
|
<div className="flex items-center gap-2">
|
||||||
<button
|
{action}
|
||||||
onClick={onClose}
|
{onClose && (
|
||||||
className="text-ink-400 transition-colors hover:text-ink-900"
|
<button
|
||||||
aria-label="关闭"
|
onClick={onClose}
|
||||||
>
|
className="text-ink-400 transition-colors hover:text-ink-900"
|
||||||
<svg viewBox="0 0 20 20" className="h-4 w-4" fill="currentColor" aria-hidden="true">
|
aria-label="关闭"
|
||||||
<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>
|
<svg viewBox="0 0 20 20" className="h-4 w-4" fill="currentColor" aria-hidden="true">
|
||||||
</button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -385,3 +396,43 @@ export function Spinner({ className = '' }: { className?: string }) {
|
|||||||
export function SkeletonBlock({ className = '' }: { className?: string }) {
|
export function SkeletonBlock({ className = '' }: { className?: string }) {
|
||||||
return <div className={`skeleton ${className}`} />
|
return <div className={`skeleton ${className}`} />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** 空状态文本 */
|
||||||
|
export function EmptyText({ text }: { text: string }) {
|
||||||
|
return (
|
||||||
|
<div className="py-10 text-center text-sm text-ink-400">
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Agent 权重条形图 */
|
||||||
|
export function AgentWeightsBar({ weights, okCount }: { weights: Record<string, number>; okCount: number }) {
|
||||||
|
const entries = Object.entries(weights).filter(([, w]) => w > 0)
|
||||||
|
if (entries.length === 0) return null
|
||||||
|
const total = entries.reduce((s, [, w]) => s + w, 0) || 1
|
||||||
|
const colors = ['bg-ink-900', 'bg-ink-700', 'bg-ink-500', 'bg-press', 'bg-ink-300']
|
||||||
|
return (
|
||||||
|
<div className="mt-2 border-t border-ink-200 pt-2">
|
||||||
|
<div className="mb-1 text-2xs text-ink-400">终裁专家权重</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{entries.map(([k, w], i) => (
|
||||||
|
<div key={k} className="flex items-center gap-2 text-2xs">
|
||||||
|
<div className="h-3.5 flex-1 overflow-hidden rounded-sm bg-ink-200/60">
|
||||||
|
<div
|
||||||
|
className={`h-full ${colors[i % colors.length]} transition-all duration-500`}
|
||||||
|
style={{ width: `${Math.max(3, Math.round((w / total) * 100))}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="w-12 text-right tabular-nums text-ink-500">
|
||||||
|
{Math.round((w / total) * 100)}%
|
||||||
|
</span>
|
||||||
|
<span className="w-24 truncate text-ink-400" title={k}>{k}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-2xs text-ink-400">有效专家:{okCount}/{entries.length}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
+182
-34
@@ -61,33 +61,16 @@ export async function fetchDashboard(): Promise<DashboardStats> {
|
|||||||
// ── 数据采集 ────────────────────────────────────────────────────
|
// ── 数据采集 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function triggerCollection(req: CollectionRequest): Promise<any> {
|
export async function triggerCollection(req: CollectionRequest): Promise<any> {
|
||||||
const sourceMap: Record<string, { path: string; body: any }> = {
|
const body: Record<string, any> = {
|
||||||
bzzoiro: {
|
leagues: req.leagues,
|
||||||
path: `${API_BASE}/ingest/bzzoiro`,
|
date_from: req.date_from,
|
||||||
body: {
|
date_to: req.date_to,
|
||||||
leagues: req.leagues,
|
status: req.status || undefined,
|
||||||
date_from: req.date_from,
|
task: req.task || 'events',
|
||||||
date_to: req.date_to,
|
limit: req.limit || 100,
|
||||||
status: req.status || undefined, // 空 = 已完赛 + 未开赛都采集
|
season: req.season || undefined,
|
||||||
},
|
|
||||||
},
|
|
||||||
understat: {
|
|
||||||
path: `${API_BASE}/ingest/understat`,
|
|
||||||
body: {
|
|
||||||
league: req.league,
|
|
||||||
season: req.season ? parseInt(req.season) : new Date().getFullYear(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
injuries: {
|
|
||||||
path: `${API_BASE}/ingest/injuries`,
|
|
||||||
body: {
|
|
||||||
date: req.date_from || new Date().toISOString().slice(0, 10),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
const cfg = sourceMap[req.source]
|
return api.post(`${API_BASE}/ingest/bzzoiro`, body)
|
||||||
if (!cfg) throw new Error(`未知数据源: ${req.source}`)
|
|
||||||
return api.post(cfg.path, cfg.body)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 预测管理 ────────────────────────────────────────────────────
|
// ── 预测管理 ────────────────────────────────────────────────────
|
||||||
@@ -183,6 +166,65 @@ export async function fetchHealth(): Promise<any> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 数据完整性 ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface DataCompletenessResponse {
|
||||||
|
generated_at: string
|
||||||
|
totals: { finished_matches: number; stats_rows: number; stats_coverage_pct: number }
|
||||||
|
issues: string[]
|
||||||
|
leagues: Array<{
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
country?: string
|
||||||
|
matches: { total: number; finished: number; scheduled: number; with_source_id: number; earliest_match?: string; latest_match?: string }
|
||||||
|
stats: {
|
||||||
|
rows: number
|
||||||
|
fields: Record<string, { count: number; pct: number }>
|
||||||
|
}
|
||||||
|
standings: { rows: number; latest_retrieved?: string }
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDataCompleteness(): Promise<DataCompletenessResponse> {
|
||||||
|
return api.get<DataCompletenessResponse>(`${API_BASE}/admin/data-completeness`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 积分榜(主站 + 管理后台共用) ─────────────────────────────────
|
||||||
|
|
||||||
|
export interface StandingRow {
|
||||||
|
position: number
|
||||||
|
team: string
|
||||||
|
team_en: string
|
||||||
|
played: number
|
||||||
|
won: number
|
||||||
|
drawn: number
|
||||||
|
lost: number
|
||||||
|
goals_for: number
|
||||||
|
goals_against: number
|
||||||
|
goal_diff: number
|
||||||
|
points: number
|
||||||
|
xg_for: number | null
|
||||||
|
xg_against: number | null
|
||||||
|
form: string | null
|
||||||
|
zone: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StandingsLeague {
|
||||||
|
league_code: string
|
||||||
|
league_name: string
|
||||||
|
season: string
|
||||||
|
retrieved_at: string | null
|
||||||
|
rows: StandingRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchStandings(league?: string, season?: string): Promise<{ leagues: StandingsLeague[] }> {
|
||||||
|
const sp = new URLSearchParams()
|
||||||
|
if (league) sp.set('league', league)
|
||||||
|
if (season) sp.set('season', season)
|
||||||
|
const qs = sp.toString()
|
||||||
|
return api.get<{ leagues: StandingsLeague[] }>(`${API_BASE}/standings${qs ? `?${qs}` : ''}`)
|
||||||
|
}
|
||||||
|
|
||||||
// ── 数据源管理 ──────────────────────────────────────────────────
|
// ── 数据源管理 ──────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -251,14 +293,17 @@ export function clearSetting(key: string) {
|
|||||||
* 测试 LLM 连接 — 调用预测端点验证
|
* 测试 LLM 连接 — 调用预测端点验证
|
||||||
*/
|
*/
|
||||||
export async function testLLMConnection(matchId?: number): Promise<any> {
|
export async function testLLMConnection(matchId?: number): Promise<any> {
|
||||||
return api.post(
|
// F4 修复: 优先使用不依赖比赛的 ping 端点
|
||||||
`${API_BASE}/predict`,
|
try {
|
||||||
{
|
return await api.post(`${API_BASE}/admin/llm/ping`, {})
|
||||||
match_id: matchId || 1,
|
} catch {
|
||||||
mode: 'single',
|
// 回退到旧方式(兼容)
|
||||||
},
|
return api.post(
|
||||||
{ timeoutMs: 300_000 },
|
`${API_BASE}/predict`,
|
||||||
)
|
{ match_id: matchId || 1, mode: 'single' },
|
||||||
|
{ timeoutMs: 300_000 },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -339,3 +384,106 @@ export function fetchMatchContext(id: number): Promise<MatchContextOut> {
|
|||||||
export function fetchAdminStats(): Promise<AdminStats> {
|
export function fetchAdminStats(): Promise<AdminStats> {
|
||||||
return api.get<AdminStats>(`${API_BASE}/admin/stats`)
|
return api.get<AdminStats>(`${API_BASE}/admin/stats`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── API Key 轮换环 ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface KeyRingKeyStatus {
|
||||||
|
masked: string
|
||||||
|
blocked_remaining: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeyRingStatusResponse {
|
||||||
|
base_url: string
|
||||||
|
total: number
|
||||||
|
has_multiple: boolean
|
||||||
|
cooldown_seconds: number
|
||||||
|
active_index: number
|
||||||
|
active_key: string | null
|
||||||
|
keys: KeyRingKeyStatus[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchKeyRingStatus(): Promise<KeyRingStatusResponse> {
|
||||||
|
return api.get<KeyRingStatusResponse>(`${API_BASE}/admin/keyring/status`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetKeyRingCooldown(): Promise<{ ok: boolean; message: string; stats: KeyRingStatusResponse }> {
|
||||||
|
return api.post<{ ok: boolean; message: string; stats: KeyRingStatusResponse }>(`${API_BASE}/admin/keyring/cooldown/reset`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 定时任务 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ScheduleItem {
|
||||||
|
id: string
|
||||||
|
task: string
|
||||||
|
cron: string
|
||||||
|
leagues?: string
|
||||||
|
enabled: boolean
|
||||||
|
last_run_at?: string | null
|
||||||
|
last_status?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSchedules(): Promise<ScheduleItem[]> {
|
||||||
|
return api.get<ScheduleItem[]>(`${API_BASE}/admin/schedules`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSchedule(data: { id: string; task: string; cron: string; leagues?: string; enabled: boolean }): Promise<{ ok: boolean }> {
|
||||||
|
return api.post<{ ok: boolean }>(`${API_BASE}/admin/schedules`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSchedule(id: string, data: Partial<ScheduleItem>): Promise<{ ok: boolean }> {
|
||||||
|
return api.put<{ ok: boolean }>(`${API_BASE}/admin/schedules/${id}`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSchedule(id: string): Promise<{ ok: boolean }> {
|
||||||
|
return api.delete<{ ok: boolean }>(`${API_BASE}/admin/schedules/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runScheduleNow(id: string): Promise<{ ok: boolean; message: string }> {
|
||||||
|
return api.post<{ ok: boolean; message: string }>(`${API_BASE}/admin/schedules/${id}/run`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 数据管线(质量检查 + 失败重试) ──────────────────────────────
|
||||||
|
|
||||||
|
export interface IngestFailureItem {
|
||||||
|
id: number
|
||||||
|
source: string
|
||||||
|
entity_type: string
|
||||||
|
source_record_id?: string
|
||||||
|
error_type: string
|
||||||
|
error_detail?: string
|
||||||
|
retry_count: number
|
||||||
|
status: string
|
||||||
|
next_retry_at?: string | null
|
||||||
|
created_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataQualityCheckItem {
|
||||||
|
id: number
|
||||||
|
check_name: string
|
||||||
|
entity_type: string
|
||||||
|
passed: boolean
|
||||||
|
severity: string
|
||||||
|
detail?: Record<string, unknown> | null
|
||||||
|
checked_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataQualityResponse {
|
||||||
|
failures: IngestFailureItem[]
|
||||||
|
checks: DataQualityCheckItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDataQuality(): Promise<DataQualityResponse> {
|
||||||
|
return api.get<DataQualityResponse>(`${API_BASE}/admin/data-quality`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runDataQualityCheck(): Promise<{ ok: boolean; checks: Array<{ name: string; passed: boolean }> }> {
|
||||||
|
return api.post<{ ok: boolean; checks: Array<{ name: string; passed: boolean }> }>(`${API_BASE}/admin/data-quality/run`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchIngestFailures(): Promise<IngestFailureItem[]> {
|
||||||
|
return api.get<IngestFailureItem[]>(`${API_BASE}/admin/ingest-failures`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function retryIngestFailure(id: number): Promise<{ ok: boolean; message: string }> {
|
||||||
|
return api.post<{ ok: boolean; message: string }>(`${API_BASE}/admin/ingest-failures/${id}/retry`)
|
||||||
|
}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ function exportCsv(rows: BacktestResultRow[]) {
|
|||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const a = document.createElement("a")
|
const a = document.createElement("a")
|
||||||
a.href = url
|
a.href = url
|
||||||
a.download = `backtest_${new Date().toISOString().slice(0, 10)}.csv`
|
a.download = `backtest_${new Date().toLocaleDateString('sv-SE')}.csv`
|
||||||
a.click()
|
a.click()
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
}
|
}
|
||||||
@@ -73,7 +73,7 @@ function csvCell(v: string): string {
|
|||||||
|
|
||||||
function fmtDate(s?: string | null): string {
|
function fmtDate(s?: string | null): string {
|
||||||
if (!s) return '—'
|
if (!s) return '—'
|
||||||
return s.slice(0, 10)
|
return new Date(s).toLocaleDateString('sv-SE')
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BacktestPage() {
|
export default function BacktestPage() {
|
||||||
@@ -82,7 +82,7 @@ export default function BacktestPage() {
|
|||||||
const [dateFrom, setDateFrom] = useState('')
|
const [dateFrom, setDateFrom] = useState('')
|
||||||
const [dateTo, setDateTo] = useState('')
|
const [dateTo, setDateTo] = useState('')
|
||||||
const [limit, setLimit] = useState(20)
|
const [limit, setLimit] = useState(20)
|
||||||
const [mode, setMode] = useState<'single' | 'multi'>('single')
|
const [mode] = useState<'multi'>('multi')
|
||||||
const [model, setModel] = useState('')
|
const [model, setModel] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -193,14 +193,12 @@ export default function BacktestPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-xs text-ink-500">模式</label>
|
<label className="mb-1.5 block text-xs text-ink-500">模式</label>
|
||||||
<select
|
<input
|
||||||
value={mode}
|
type="text"
|
||||||
onChange={e => setMode(e.target.value as 'single' | 'multi')}
|
value="多专家 (5 路 + 终裁)"
|
||||||
className="field w-full"
|
readOnly
|
||||||
>
|
className="field w-full bg-paper-100 text-ink-500"
|
||||||
<option value="single">单次调用 (快)</option>
|
/>
|
||||||
<option value="multi">多专家 (慢,贵)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -281,7 +279,7 @@ export default function BacktestPage() {
|
|||||||
</div>
|
</div>
|
||||||
{summary.degraded > 0 && (
|
{summary.degraded > 0 && (
|
||||||
<div className="col-span-full border-l-2 border-press bg-press-wash/40 px-3 py-2 text-2xs leading-relaxed text-press-dark">
|
<div className="col-span-full border-l-2 border-press bg-press-wash/40 px-3 py-2 text-2xs leading-relaxed text-press-dark">
|
||||||
有 {summary.degraded} 场预测降级(专家无有效结论),未计入准确率分子。建议检查该时段数据完整性或改用单次模式。
|
有 {summary.degraded} 场预测降级(专家无有效结论),未计入准确率分子。建议检查该时段数据完整性。
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,57 +1,62 @@
|
|||||||
/**
|
/**
|
||||||
* Admin 后台 - 数据采集页面(报刊风)
|
* Admin 后台 - 数据采集页面(bzzoiro 单一数据源)
|
||||||
|
*
|
||||||
|
* 三个采集任务:
|
||||||
|
* events — 比赛日程与比分
|
||||||
|
* standings — 联赛积分榜
|
||||||
|
* stats — 已完赛比赛详细统计回填
|
||||||
|
* all — 依次执行以上三项
|
||||||
*
|
*
|
||||||
* 响应式布局: 移动端单列,桌面端双列
|
* 响应式布局: 移动端单列,桌面端双列
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||||
import { triggerCollection, fetchLeagues } from '../dal'
|
import { triggerCollection, fetchLeagues, fetchIngestStatus } from '../dal'
|
||||||
|
import type { IngestSourceStatus } from '../types'
|
||||||
import type { CollectionRequest, League } from '../types'
|
import type { CollectionRequest, League } from '../types'
|
||||||
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
|
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
|
||||||
|
|
||||||
const SOURCES = [
|
const TASKS = [
|
||||||
{ value: 'bzzoiro', label: 'Bzzoiro', desc: '历史赛程与比分' },
|
{ value: 'events', label: '比赛数据', desc: '赛程 / 比分 / 未开赛安排', icon: '⚽' },
|
||||||
{ value: 'understat', label: 'Understat', desc: 'xG 进阶数据' },
|
{ value: 'standings', label: '积分榜', desc: '联赛排名 / 积分 / xG差 / 近期走势', icon: '🏆' },
|
||||||
{ value: 'injuries', label: 'Injuries', desc: '球员伤停' },
|
{ value: 'stats', label: '统计回填', desc: '已完赛比赛的 xG / 射门 / 控球等详细统计', icon: '📊' },
|
||||||
|
{ value: 'all', label: '全量采集', desc: '依次采集比赛 + 积分榜 + 统计回填', icon: '⏵⏵' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
/** 把采集接口返回摘要成一两行可读文字 */
|
type TaskStatus = 'idle' | 'running' | 'done' | 'error'
|
||||||
function summarizeResult(res: any, source: string): { title: string; detail: string } {
|
|
||||||
if (res && typeof res === 'object') {
|
|
||||||
if (source === 'bzzoiro' && ('total_inserted' in res || 'total_updated' in res)) {
|
|
||||||
return {
|
|
||||||
title: `采集完成:新增 ${res.total_inserted ?? 0} 条,更新 ${res.total_updated ?? 0} 条`,
|
|
||||||
detail: Array.isArray(res.errors) && res.errors.length > 0 ? res.errors.join('\n') : '',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ('count' in res || 'updated' in res) {
|
|
||||||
const parts = [
|
|
||||||
`新增 ${res.count ?? 0}`,
|
|
||||||
`更新 ${res.updated ?? 0}`,
|
|
||||||
`跳过 ${res.skipped ?? 0}`,
|
|
||||||
`未匹配 ${res.unmatched ?? 0}`,
|
|
||||||
]
|
|
||||||
return {
|
|
||||||
title: `采集完成:${parts.join(' / ')}`,
|
|
||||||
detail: Array.isArray(res.errors) && res.errors.length > 0 ? res.errors.join('\n') : '',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { title: '采集完成', detail: JSON.stringify(res)?.slice(0, 300) ?? '' }
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CollectionPage() {
|
export default function CollectionPage() {
|
||||||
const [leagues, setLeagues] = useState<League[]>([])
|
const [leagues, setLeagues] = useState<League[]>([])
|
||||||
const [source, setSource] = useState<string>('bzzoiro')
|
const [task, setTask] = useState<string>('events')
|
||||||
const [leagueCode, setLeagueCode] = useState('')
|
const [leagueCode, setLeagueCode] = useState('')
|
||||||
|
|
||||||
|
// 从 URL 查询参数预填充(支持从「数据完整性」页跳转)
|
||||||
|
useEffect(() => {
|
||||||
|
const sp = new URLSearchParams(window.location.search)
|
||||||
|
const taskParam = sp.get('task')
|
||||||
|
const leagueParam = sp.get('league')
|
||||||
|
if (taskParam && ['events', 'standings', 'stats', 'all'].includes(taskParam)) {
|
||||||
|
setTask(taskParam)
|
||||||
|
}
|
||||||
|
if (leagueParam) {
|
||||||
|
setLeagueCode(leagueParam)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
const [dateFrom, setDateFrom] = useState('')
|
const [dateFrom, setDateFrom] = useState('')
|
||||||
const [dateTo, setDateTo] = useState('')
|
const [dateTo, setDateTo] = useState('')
|
||||||
const [season, setSeason] = useState('')
|
const [season, setSeason] = useState('')
|
||||||
const [ingestStatus, setIngestStatus] = useState('') // 空 = 已完赛+未开赛
|
const [ingestStatus, setIngestStatus] = useState('')
|
||||||
|
const [limit, setLimit] = useState(100)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [result, setResult] = useState<{ title: string; detail: string } | null>(null)
|
const [result, setResult] = useState<{ title: string; detail: string } | null>(null)
|
||||||
|
|
||||||
|
// 任务进度反馈
|
||||||
|
const [taskStatus, setTaskStatus] = useState<TaskStatus>('idle')
|
||||||
|
const [taskStartedAt, setTaskStartedAt] = useState<number | null>(null)
|
||||||
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
const [ingestSnap, setIngestSnap] = useState<IngestSourceStatus | null>(null)
|
||||||
|
|
||||||
const loadLeagues = useCallback(async () => {
|
const loadLeagues = useCallback(async () => {
|
||||||
const lg = await fetchLeagues()
|
const lg = await fetchLeagues()
|
||||||
setLeagues(lg)
|
setLeagues(lg)
|
||||||
@@ -59,39 +64,73 @@ export default function CollectionPage() {
|
|||||||
|
|
||||||
useEffect(() => { loadLeagues() }, [loadLeagues])
|
useEffect(() => { loadLeagues() }, [loadLeagues])
|
||||||
|
|
||||||
|
// 轮询采集状态(任务启动后)
|
||||||
|
const startPolling = useCallback(() => {
|
||||||
|
if (pollRef.current) clearInterval(pollRef.current)
|
||||||
|
pollRef.current = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const { sources } = await fetchIngestStatus()
|
||||||
|
const bz = sources.find(s => s.name === 'bzzoiro')
|
||||||
|
if (bz) setIngestSnap(bz)
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, 5_000)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const stopPolling = useCallback(() => {
|
||||||
|
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => () => stopPolling(), [stopPolling])
|
||||||
|
|
||||||
|
const isEventsTask = task === 'events' || task === 'all'
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError(null)
|
setError(null)
|
||||||
setResult(null)
|
setResult(null)
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
setTaskStatus('running')
|
||||||
|
setTaskStartedAt(Date.now())
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body: CollectionRequest = {
|
const body: CollectionRequest = {
|
||||||
source: source as CollectionRequest['source'],
|
source: 'bzzoiro',
|
||||||
leagues: leagueCode ? [leagueCode] : undefined,
|
leagues: leagueCode ? [leagueCode] : undefined,
|
||||||
league: leagueCode || undefined,
|
task: task as CollectionRequest['task'],
|
||||||
|
limit,
|
||||||
season: season || undefined,
|
season: season || undefined,
|
||||||
status: ingestStatus || undefined,
|
status: ingestStatus || undefined,
|
||||||
date_from: dateFrom || undefined,
|
date_from: isEventsTask ? dateFrom || undefined : undefined,
|
||||||
date_to: dateTo || undefined,
|
date_to: isEventsTask ? dateTo || undefined : undefined,
|
||||||
}
|
}
|
||||||
await triggerCollection(body)
|
await triggerCollection(body)
|
||||||
setResult({
|
setResult({
|
||||||
title: '采集任务已启动',
|
title: '采集任务已启动',
|
||||||
detail: '正在后台执行(上游限速时可能需要几分钟)。完成结果与错误请到「系统日志」页查看(支持自动刷新)。',
|
detail: '正在后台执行(上游限速时可能需要数分钟)。完成结果与错误请到「系统日志」页查看(支持自动刷新)。',
|
||||||
})
|
})
|
||||||
|
// 启动轮询,跟踪状态
|
||||||
|
startPolling()
|
||||||
|
// 30 秒后自动停止轮询并标记完成
|
||||||
|
setTimeout(() => {
|
||||||
|
setTaskStatus('done')
|
||||||
|
stopPolling()
|
||||||
|
}, 30_000)
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
|
setTaskStatus('error')
|
||||||
setError(err instanceof Error ? err.message : '采集触发失败')
|
setError(err instanceof Error ? err.message : '采集触发失败')
|
||||||
|
stopPolling()
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const elapsed = taskStartedAt ? Math.round((Date.now() - taskStartedAt) / 1000) : 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="数据采集"
|
title="数据采集"
|
||||||
description="触发数据源采集,支持联赛筛选和日期范围。采集为同步执行,大范围日期耗时较长。"
|
description="bzzoiro 单一数据源:比赛数据、积分榜、比赛统计三条管线。采集为后台异步执行。"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
@@ -100,20 +139,26 @@ export default function CollectionPage() {
|
|||||||
<CardHeader title="新建采集任务" />
|
<CardHeader title="新建采集任务" />
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
{/* 数据源选择 */}
|
{/* 任务类型 */}
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-xs text-ink-500">数据源</label>
|
<label className="mb-1.5 block text-xs text-ink-500">采集任务</label>
|
||||||
<select
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||||
value={source}
|
{TASKS.map(t => (
|
||||||
onChange={e => setSource(e.target.value)}
|
<button
|
||||||
className="field w-full"
|
key={t.value}
|
||||||
>
|
type="button"
|
||||||
{SOURCES.map(s => (
|
onClick={() => setTask(t.value)}
|
||||||
<option key={s.value} value={s.value}>
|
className={`rounded-lg border px-3 py-2 text-left text-xs transition-colors ${
|
||||||
{s.label} — {s.desc}
|
task === t.value
|
||||||
</option>
|
? 'border-brand-500 bg-brand-50 text-brand-700'
|
||||||
|
: 'border-ink-200 text-ink-600 hover:border-ink-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="mr-1">{t.icon}</span>
|
||||||
|
<span className="font-medium">{t.label}</span>
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
</select>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 联赛选择 */}
|
{/* 联赛选择 */}
|
||||||
@@ -131,57 +176,73 @@ export default function CollectionPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bzzoiro 专用: 比赛状态 */}
|
{/* events/all 任务专用: 比赛状态 + 日期 */}
|
||||||
{source === 'bzzoiro' && (
|
{isEventsTask && (
|
||||||
<div>
|
<>
|
||||||
<label className="mb-1.5 block text-xs text-ink-500">比赛状态</label>
|
<div>
|
||||||
<select
|
<label className="mb-1.5 block text-xs text-ink-500">比赛状态</label>
|
||||||
value={ingestStatus}
|
<select
|
||||||
onChange={e => setIngestStatus(e.target.value)}
|
value={ingestStatus}
|
||||||
className="field w-full"
|
onChange={e => setIngestStatus(e.target.value)}
|
||||||
>
|
className="field w-full"
|
||||||
<option value="">全部(已完赛 + 未开赛)</option>
|
>
|
||||||
<option value="finished">仅已完赛</option>
|
<option value="">全部(已完赛 + 未开赛)</option>
|
||||||
<option value="scheduled">仅未开赛</option>
|
<option value="finished">仅已完赛</option>
|
||||||
</select>
|
<option value="scheduled">仅未开赛</option>
|
||||||
</div>
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-xs text-ink-500">起始日期</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dateFrom}
|
||||||
|
onChange={e => setDateFrom(e.target.value)}
|
||||||
|
className="field w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-xs text-ink-500">结束日期</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dateTo}
|
||||||
|
onChange={e => setDateTo(e.target.value)}
|
||||||
|
className="field w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Understat 专用: 赛季 */}
|
{/* standings 任务专用: 赛季 */}
|
||||||
{source === 'understat' && (
|
{(task === 'standings') && (
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-xs text-ink-500">赛季(起始年)</label>
|
<label className="mb-1.5 block text-xs text-ink-500">赛季(留空取当前赛季)</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="text"
|
||||||
value={season}
|
value={season}
|
||||||
onChange={e => setSeason(e.target.value)}
|
onChange={e => setSeason(e.target.value)}
|
||||||
placeholder="2025"
|
placeholder="如 2026-2027"
|
||||||
className="field w-full"
|
className="field w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 日期范围 */}
|
{/* stats 任务专用: 回填数量 */}
|
||||||
{source !== 'injuries' && (
|
{(task === 'stats') && (
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<div>
|
||||||
<div>
|
<label className="mb-1.5 block text-xs text-ink-500">单次最大回填比赛数(1-500)</label>
|
||||||
<label className="mb-1.5 block text-xs text-ink-500">起始日期</label>
|
<input
|
||||||
<input
|
type="number"
|
||||||
type="date"
|
min={1}
|
||||||
value={dateFrom}
|
max={500}
|
||||||
onChange={e => setDateFrom(e.target.value)}
|
value={limit}
|
||||||
className="field w-full"
|
onChange={e => setLimit(parseInt(e.target.value) || 100)}
|
||||||
/>
|
className="field w-full"
|
||||||
</div>
|
/>
|
||||||
<div>
|
<p className="mt-1 text-2xs text-ink-400">
|
||||||
<label className="mb-1.5 block text-xs text-ink-500">结束日期</label>
|
仅回填已有 source_event_id 且无统计的比赛(增量),上游限速约 1.2 秒/次。
|
||||||
<input
|
</p>
|
||||||
type="date"
|
|
||||||
value={dateTo}
|
|
||||||
onChange={e => setDateTo(e.target.value)}
|
|
||||||
className="field w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -204,27 +265,74 @@ export default function CollectionPage() {
|
|||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 数据源说明 */}
|
{/* 任务状态 + 数据源说明 */}
|
||||||
<Card>
|
<div className="space-y-6">
|
||||||
<CardHeader title="数据源说明" />
|
{/* 任务进度 */}
|
||||||
<CardBody>
|
<Card>
|
||||||
<div className="space-y-3">
|
<CardHeader title="任务状态" />
|
||||||
{SOURCES.map(s => (
|
<CardBody>
|
||||||
<div key={s.value} className="border-b border-ink-200 px-1 py-3 last:border-b-0">
|
{taskStatus === 'idle' && (
|
||||||
<div className="flex items-center gap-3">
|
<p className="text-xs text-ink-400">尚未触发任务。</p>
|
||||||
<Badge status="info">{s.label}</Badge>
|
)}
|
||||||
<p className="text-xs text-ink-600">{s.desc}</p>
|
{taskStatus === 'running' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-ink-700">
|
||||||
|
<Spinner />
|
||||||
|
<span>任务执行中,已运行 {elapsed}s…</span>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-2xs text-ink-400">
|
||||||
|
后台异步执行,关闭页面不影响结果。可稍后查看「系统日志」确认完成。
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
</div>
|
{taskStatus === 'done' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-emerald-700">
|
||||||
|
<span className="inline-block h-2 w-2 rounded-full bg-emerald-500" />
|
||||||
|
<span>任务已提交,后台执行中(可能尚未完成)</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xs text-ink-400">
|
||||||
|
采集耗时取决于数据量。请到「系统日志」页查看最终结果。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{taskStatus === 'error' && (
|
||||||
|
<p className="text-xs text-press">任务触发失败,请检查配置或网络。</p>
|
||||||
|
)}
|
||||||
|
{ingestSnap?.last_success_at && (
|
||||||
|
<div className="mt-3 border-t border-ink-100 pt-3">
|
||||||
|
<p className="text-2xs text-ink-400">
|
||||||
|
bzzoiro 最近一次采集: {new Date(ingestSnap.last_success_at).toLocaleString('zh-CN', { hour12: false })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<p className="mt-4 border-l-2 border-ink-300 pl-3 text-2xs leading-relaxed text-ink-500">
|
{/* 数据源说明 */}
|
||||||
采集接口需要管理员登录。
|
<Card>
|
||||||
遇到 401 表示登录已过期,请重新登录。
|
<CardHeader title="采集任务说明" />
|
||||||
</p>
|
<CardBody>
|
||||||
</CardBody>
|
<div className="space-y-3">
|
||||||
</Card>
|
{TASKS.map(t => (
|
||||||
|
<div key={t.value} className="border-b border-ink-200 px-1 py-3 last:border-b-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span>{t.icon}</span>
|
||||||
|
<Badge status="info">{t.label}</Badge>
|
||||||
|
<p className="text-xs text-ink-600">{t.desc}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-4 border-l-2 border-ink-300 pl-3 text-2xs leading-relaxed text-ink-500">
|
||||||
|
采集接口需要管理员登录(401 表示登录已过期)。
|
||||||
|
各管线基于 bzzoiro 单一数据源(Understat / injuries 已移除)。
|
||||||
|
「统计回填」依赖「比赛数据」管线写入的 source_event_id,请先完成比赛采集。
|
||||||
|
</p>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,141 +1,178 @@
|
|||||||
/**
|
/**
|
||||||
* Admin 后台 - 仪表盘(报刊风)
|
* Admin 后台 - 仪表盘(报刊风)
|
||||||
*
|
*
|
||||||
* 响应式: 移动端 1 列 → 平板 2 列 → 桌面 4 列
|
* 展示:
|
||||||
|
* - 数据流水线状态(采集 → 预测 → 评估,每步的实际数据量)
|
||||||
|
* - 近期预测活动(24h / 7d / 总计)
|
||||||
|
* - 快捷操作入口(带工作流引导)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
import { fetchDashboard, fetchHealth } from '../dal'
|
import { fetchAdminStats, fetchIngestStatus, fetchDashboard } from '../dal'
|
||||||
import type { DashboardStats } from '../types'
|
import type { AdminStats, IngestSourceStatus, DashboardStats } from '../types'
|
||||||
import { Card, CardBody, CardHeader, StatCard, Alert, SkeletonBlock } from '../components'
|
import { Card, CardBody, CardHeader, Alert, SkeletonBlock } from '../components'
|
||||||
|
|
||||||
|
/** 工作流步骤卡片 */
|
||||||
|
const STEPS = [
|
||||||
|
{ to: '/admin/collection', step: '1', title: '采集数据', desc: 'bzzoiro: 赛程 / 积分榜 / 比赛统计(xG、射门、控球等)', icon: '◈' },
|
||||||
|
{ to: '/admin/predictions', step: '2', title: '运行预测', desc: '调 LLM 多专家生成比分预测', icon: '◆' },
|
||||||
|
{ to: '/admin/eval', step: '3', title: '评估准确率', desc: '结算后查看 1X2 命中率与校准度', icon: '◈' },
|
||||||
|
]
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const [data, setData] = useState<DashboardStats | null>(null)
|
const [stats, setStats] = useState<AdminStats | null>(null)
|
||||||
const [health, setHealth] = useState<any>(null)
|
const [ingest, setIngest] = useState<IngestSourceStatus[]>([])
|
||||||
|
const [dash, setDash] = useState<DashboardStats | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
const load = useCallback(async () => {
|
||||||
let active = true
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
Promise.all([fetchDashboard(), fetchHealth()])
|
const [s, i, d] = await Promise.allSettled([
|
||||||
.then(([stats, h]) => {
|
fetchAdminStats(),
|
||||||
if (active) {
|
fetchIngestStatus(),
|
||||||
setData(stats)
|
fetchDashboard(),
|
||||||
setHealth(h)
|
])
|
||||||
}
|
if (s.status === 'fulfilled') setStats(s.value)
|
||||||
})
|
if (i.status === 'fulfilled') setIngest(i.value.sources)
|
||||||
.catch((err: unknown) => {
|
if (d.status === 'fulfilled') setDash(d.value)
|
||||||
if (active) setError(err instanceof Error ? err.message : '加载失败')
|
setLoading(false)
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (active) setLoading(false)
|
|
||||||
})
|
|
||||||
return () => { active = false }
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
if (error) {
|
useEffect(() => { load() }, [load])
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Alert kind="error" title="加载仪表盘失败" message={error} />
|
|
||||||
<button onClick={() => window.location.reload()} className="btn btn-sm">
|
|
||||||
重试
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const healthText =
|
const sourceByName = Object.fromEntries(ingest.map(s => [s.name, s]))
|
||||||
health?.status === 'healthy' || health?.status === 'ok' ? '正常' : '异常'
|
const bzzoiro = sourceByName['bzzoiro']
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* 统计:报纸数字版式 */}
|
{/* ── 工作流引导(采集 → 预测 → 评估) ── */}
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
<StatCard
|
{STEPS.map((s, i) => (
|
||||||
label="健康状态"
|
<a
|
||||||
value={loading ? '—' : healthText}
|
key={s.to}
|
||||||
hint={loading ? undefined : '每 60 秒随报眉自动复检'}
|
href={s.to}
|
||||||
/>
|
className="group border border-ink-300 p-4 transition-colors hover:border-ink-900 hover:bg-paper-100"
|
||||||
<StatCard label="联赛数" value={loading ? '—' : data?.leagues.length ?? 0} />
|
>
|
||||||
<StatCard label="比赛数" value={loading ? '—' : data?.total_matches ?? 0} />
|
<div className="flex items-center gap-2.5">
|
||||||
<StatCard label="预测数" value={loading ? '—' : data?.total_predictions ?? 0} />
|
<span className="flex h-7 w-7 items-center justify-center border border-ink-900 font-serif text-xs font-bold text-ink-900">
|
||||||
|
{s.step}
|
||||||
|
</span>
|
||||||
|
<span className="font-serif text-sm font-bold text-ink-900">{s.title}</span>
|
||||||
|
<span className="ml-auto text-ink-300 transition-colors group-hover:text-press" aria-hidden="true">→</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-2xs leading-relaxed text-ink-500">{s.desc}</p>
|
||||||
|
{i < STEPS.length - 1 && <span className="sr-only">下一步</span>}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 联赛列表 */}
|
{/* ── 数据源健康一览 ── */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader title="已入库联赛" />
|
<CardHeader
|
||||||
<CardBody>
|
title="数据源健康"
|
||||||
|
description="各源最近采集时间与数据量(只读快照,详细配置见「数据源」页)"
|
||||||
|
/>
|
||||||
|
<CardBody className="px-0">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="space-y-2 px-4 sm:px-5">
|
||||||
{[1, 2, 3, 4].map(i => (
|
{[1, 2, 3].map(i => <SkeletonBlock key={i} className="h-8 w-full" />)}
|
||||||
<SkeletonBlock key={i} className="h-6 w-24" />
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
) : data && data.leagues.length > 0 ? (
|
) : (
|
||||||
|
<div>
|
||||||
|
{[
|
||||||
|
{ name: 'bzzoiro', label: 'Bzzoiro', st: bzzoiro },
|
||||||
|
].map(({ name, label, st }) => {
|
||||||
|
const hasData = st && st.recent_count > 0
|
||||||
|
const keyOk = st?.key_configured !== false
|
||||||
|
return (
|
||||||
|
<div key={name} className="flex items-center justify-between border-b border-ink-200 px-4 py-2.5 last:border-b-0 sm:px-5">
|
||||||
|
<span className="text-xs font-medium text-ink-700">{label}</span>
|
||||||
|
<span className="flex items-center gap-3 text-2xs">
|
||||||
|
{hasData ? (
|
||||||
|
<>
|
||||||
|
<span className="tabular-nums text-ink-500">{st.recent_count.toLocaleString()} 条</span>
|
||||||
|
<span className="text-ink-400">{st.last_success_at ? new Date(st.last_success_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }) : ''}</span>
|
||||||
|
</>
|
||||||
|
) : keyOk ? (
|
||||||
|
<span className="text-ink-400">无数据</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-press">未配置 Key</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`inline-block h-1.5 w-1.5 ${hasData && keyOk ? 'bg-ink-900' : 'bg-press'}`}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ── 近期预测活动 ── */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader title="近期预测活动" description="预测 API 的调用量统计" />
|
||||||
|
<CardBody>
|
||||||
|
{stats ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-3 gap-4 text-center">
|
||||||
|
<div>
|
||||||
|
<div className="font-serif text-3xl font-bold tabular-nums text-ink-900">{stats.predictions.last_24h}</div>
|
||||||
|
<div className="mt-1 text-2xs text-ink-400">近 24 小时</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-serif text-3xl font-bold tabular-nums text-ink-900">{stats.predictions.last_7d}</div>
|
||||||
|
<div className="mt-1 text-2xs text-ink-400">近 7 天</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-serif text-3xl font-bold tabular-nums text-ink-900">{stats.predictions.total}</div>
|
||||||
|
<div className="mt-1 text-2xs text-ink-400">累计</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* F3 修复: 真实比赛计数(非 limit=100 近似) */}
|
||||||
|
<div className="grid grid-cols-4 gap-3 border-t border-ink-200 pt-3 text-center">
|
||||||
|
<div>
|
||||||
|
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">{stats.matches?.total ?? 0}</div>
|
||||||
|
<div className="mt-0.5 text-2xs text-ink-400">比赛总数</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">{stats.matches?.finished ?? 0}</div>
|
||||||
|
<div className="mt-0.5 text-2xs text-ink-400">已完赛</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">{stats.stats?.total ?? 0}</div>
|
||||||
|
<div className="mt-0.5 text-2xs text-ink-400">统计行</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">{stats.standings?.total ?? 0}</div>
|
||||||
|
<div className="mt-0.5 text-2xs text-ink-400">积分榜</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex justify-center py-4"><SkeletonBlock className="h-12 w-64" /></div>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ── 已入库联赛 ── */}
|
||||||
|
{dash && dash.leagues.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader title="已入库联赛" />
|
||||||
|
<CardBody>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{data.leagues.map(l => (
|
{dash.leagues.map(l => (
|
||||||
<span
|
<span key={l.code} className="border border-ink-200 px-2.5 py-1 text-xs text-ink-700">
|
||||||
key={l.code}
|
|
||||||
className="border border-ink-200 px-2.5 py-1 text-xs text-ink-700"
|
|
||||||
>
|
|
||||||
{l.name_zh || l.name}
|
{l.name_zh || l.name}
|
||||||
<span className="ml-1.5 font-mono text-2xs text-ink-400">{l.code}</span>
|
<span className="ml-1.5 font-mono text-2xs text-ink-400">{l.code}</span>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</CardBody>
|
||||||
<p className="text-xs text-ink-500">
|
</Card>
|
||||||
暂无联赛数据,请先到「数据采集」导入比赛数据。
|
)}
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 快捷操作 */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader title="快捷操作" />
|
|
||||||
<CardBody>
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
<a
|
|
||||||
href="/admin/collection"
|
|
||||||
className="group flex items-center justify-between border border-ink-300 p-4 transition-colors hover:border-ink-900 hover:bg-paper-100"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div className="font-serif text-sm font-bold text-ink-900">触发采集</div>
|
|
||||||
<div className="mt-0.5 text-2xs text-ink-500">从数据源获取最新赛程与比分</div>
|
|
||||||
</div>
|
|
||||||
<span className="text-ink-300 transition-colors group-hover:text-press" aria-hidden="true">
|
|
||||||
→
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="/admin/predictions"
|
|
||||||
className="group flex items-center justify-between border border-ink-300 p-4 transition-colors hover:border-ink-900 hover:bg-paper-100"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div className="font-serif text-sm font-bold text-ink-900">新建预测</div>
|
|
||||||
<div className="mt-0.5 text-2xs text-ink-500">调 LLM 生成比赛预测</div>
|
|
||||||
</div>
|
|
||||||
<span className="text-ink-300 transition-colors group-hover:text-press" aria-hidden="true">
|
|
||||||
→
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="/admin/backtest"
|
|
||||||
className="group flex items-center justify-between border border-ink-300 p-4 transition-colors hover:border-ink-900 hover:bg-paper-100"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div className="font-serif text-sm font-bold text-ink-900">运行回测</div>
|
|
||||||
<div className="mt-0.5 text-2xs text-ink-500">在历史数据上检验准确率</div>
|
|
||||||
</div>
|
|
||||||
<span className="text-ink-300 transition-colors group-hover:text-press" aria-hidden="true">
|
|
||||||
→
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
/**
|
||||||
|
* Admin 后台 - 数据完整性分析页
|
||||||
|
*
|
||||||
|
* 回答三个问题:
|
||||||
|
* 1. 数据是否齐全(各联赛比赛/统计/积分榜量级)
|
||||||
|
* 2. 字段是否齐全(每张统计表各字段非空率)
|
||||||
|
* 3. 覆盖是否新鲜(最近一场/最近一次采集)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||||
|
import { fetchDataCompleteness } from '../dal'
|
||||||
|
import type { DataCompletenessResponse } from '../dal'
|
||||||
|
import {
|
||||||
|
Card, CardBody, CardHeader, SectionHeader, Alert,
|
||||||
|
ProgressBar, Spinner, EmptyState,
|
||||||
|
} from '../components'
|
||||||
|
|
||||||
|
const FIELD_LABELS: Record<string, string> = {
|
||||||
|
xg: 'xG 预期进球',
|
||||||
|
shots: '射门',
|
||||||
|
possession: '控球率',
|
||||||
|
corners: '角球',
|
||||||
|
fouls: '犯规',
|
||||||
|
big_chances: '绝佳机会',
|
||||||
|
cards: '红黄牌',
|
||||||
|
}
|
||||||
|
|
||||||
|
function pctColor(pct: number): string {
|
||||||
|
if (pct >= 80) return 'bg-emerald-500'
|
||||||
|
if (pct >= 50) return 'bg-amber-500'
|
||||||
|
return 'bg-rose-500'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据问题描述生成可操作的修复链接 */
|
||||||
|
function getIssueAction(issue: string): { href: string; label: string; code: string } | null {
|
||||||
|
// 提取联赛代码(大写字母+数字,如 E0, SP1)
|
||||||
|
const codeMatch = issue.match(/\b([A-Z]{1,2}\d?)\b/)
|
||||||
|
const code = codeMatch ? codeMatch[1] : ''
|
||||||
|
if (!code) return null
|
||||||
|
|
||||||
|
if (issue.includes('无已完赛比赛')) {
|
||||||
|
return { href: `/admin/collection?task=events&league=${code}`, label: '去采集', code }
|
||||||
|
}
|
||||||
|
if (issue.includes('无统计回填') || issue.includes('统计覆盖率')) {
|
||||||
|
return { href: `/admin/collection?task=stats&league=${code}`, label: '去回填', code }
|
||||||
|
}
|
||||||
|
if (issue.includes('无积分榜')) {
|
||||||
|
return { href: `/admin/collection?task=standings&league=${code}`, label: '去采集', code }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DataCompletenessPage() {
|
||||||
|
const [data, setData] = useState<DataCompletenessResponse | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [highlightedLeague, setHighlightedLeague] = useState<string | null>(null)
|
||||||
|
const leagueRefs = useRef<Record<string, HTMLDivElement | null>>({})
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const d = await fetchDataCompleteness()
|
||||||
|
setData(d)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '加载失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
// 点击问题项 → 滚动到对应联赛卡片并高亮
|
||||||
|
const scrollToLeague = useCallback((code: string) => {
|
||||||
|
setHighlightedLeague(code)
|
||||||
|
// 使用 requestAnimationFrame 确保 DOM 已更新
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const el = leagueRefs.current[code]
|
||||||
|
if (el) {
|
||||||
|
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// 5秒后移除高亮
|
||||||
|
setTimeout(() => setHighlightedLeague(null), 5000)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<SectionHeader
|
||||||
|
title="数据完整性"
|
||||||
|
description="按联赛统计 bzzoiro 数据采集覆盖度。每 5 秒自动刷新,或点击右上角按钮手动刷新。"
|
||||||
|
action={
|
||||||
|
<button onClick={load} disabled={loading} className="btn-sm btn-outline">
|
||||||
|
{loading ? <><Spinner /> 刷新中</> : '刷新'}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && <Alert kind="error" title="加载失败" message={error} onClose={() => setError(null)} />}
|
||||||
|
|
||||||
|
{loading && !data && (
|
||||||
|
<div className="flex justify-center py-12"><Spinner /></div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data && (
|
||||||
|
<>
|
||||||
|
{/* 全局概览 */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<Card>
|
||||||
|
<CardBody className="text-center">
|
||||||
|
<p className="text-2xl font-bold text-ink-900">{data.totals.finished_matches}</p>
|
||||||
|
<p className="text-xs text-ink-500">已完赛比赛(总计)</p>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardBody className="text-center">
|
||||||
|
<p className="text-2xl font-bold text-ink-900">{data.totals.stats_rows}</p>
|
||||||
|
<p className="text-xs text-ink-500">统计行数</p>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardBody className="text-center">
|
||||||
|
<p className="text-2xl font-bold text-ink-900">
|
||||||
|
{data.totals.stats_coverage_pct}%
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-ink-500">统计覆盖率(有统计 / 已完赛)</p>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 健康问题(可操作) */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader title="健康摘要" />
|
||||||
|
<CardBody>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{data.issues.map((issue, i) => {
|
||||||
|
const action = getIssueAction(issue)
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
key={i}
|
||||||
|
kind={issue.includes('良好') ? 'ok' : issue.includes('建议') || issue.includes('仅') ? 'warning' : 'error'}
|
||||||
|
title={issue.includes('良好') ? '数据良好' : '需要关注'}
|
||||||
|
message={issue}
|
||||||
|
action={action ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => scrollToLeague(action.code)}
|
||||||
|
className="btn btn-sm whitespace-nowrap"
|
||||||
|
>
|
||||||
|
定位
|
||||||
|
</button>
|
||||||
|
<a href={action.href} className="btn btn-sm whitespace-nowrap">
|
||||||
|
{action.label}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
) : undefined}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 各联赛详情 */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{data.leagues.map(league => {
|
||||||
|
const finished = league.matches.finished
|
||||||
|
const statsPct = finished > 0 ? Math.round((league.stats.rows / finished) * 100) : 0
|
||||||
|
const isHighlighted = highlightedLeague === league.code
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={league.code}
|
||||||
|
ref={el => { leagueRefs.current[league.code] = el }}
|
||||||
|
className={`transition-all duration-500 ${isHighlighted ? 'ring-2 ring-press bg-press-wash/40 scale-[1.01]' : ''}`}
|
||||||
|
>
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
title={league.name}
|
||||||
|
description={[
|
||||||
|
league.country,
|
||||||
|
`已完赛 ${finished} 场 / 未开赛 ${league.matches.scheduled} 场`,
|
||||||
|
league.matches.latest_match ? `最近: ${league.matches.latest_match.slice(0, 10)}` : '',
|
||||||
|
league.standings.rows > 0 ? `积分榜 ${league.standings.rows} 队` : '',
|
||||||
|
].filter(Boolean).join(' · ')}
|
||||||
|
/>
|
||||||
|
<CardBody className="space-y-4">
|
||||||
|
{/* 统计覆盖率进度条 */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 flex items-center justify-between text-xs">
|
||||||
|
<span className="text-ink-600">统计回填覆盖率</span>
|
||||||
|
<span className="font-medium text-ink-900">
|
||||||
|
{league.stats.rows} / {finished} ({statsPct}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ProgressBar value={statsPct} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 字段覆盖率矩阵 */}
|
||||||
|
{league.stats.rows > 0 ? (
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-xs font-medium text-ink-600">字段覆盖率(有值行数 / 总行数)</p>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{Object.entries(league.stats.fields).map(([key, info]) => (
|
||||||
|
<div key={key} className="rounded border border-ink-100 px-3 py-2">
|
||||||
|
<div className="mb-1 flex items-center justify-between">
|
||||||
|
<span className="text-xs text-ink-600">{FIELD_LABELS[key] ?? key}</span>
|
||||||
|
<span className="text-xs font-medium text-ink-900">{info.pct}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 w-full overflow-hidden rounded-full bg-ink-100">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full ${pctColor(info.pct)}`}
|
||||||
|
style={{ width: `${info.pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 text-2xs text-ink-400">{info.count} / {league.stats.rows} 行</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : finished > 0 ? (
|
||||||
|
<Alert kind="warning" title="缺少统计数据" message="该联赛有已完赛比赛但无统计行,请运行「统计回填」采集。" />
|
||||||
|
) : (
|
||||||
|
<Alert kind="warning" title="缺少比赛数据" message="该联赛暂无已完赛比赛,请运行「比赛数据」采集。" />
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-center text-2xs text-ink-400">
|
||||||
|
生成时间: {new Date(data.generated_at).toLocaleString('zh-CN', { hour12: false })}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
/**
|
||||||
|
* Admin 后台 - 数据管线管理页(报刊风)
|
||||||
|
*
|
||||||
|
* 功能:
|
||||||
|
* - 采集失败记录列表(可重试)
|
||||||
|
* - 数据质量检查结果
|
||||||
|
* - 手动触发质量检查
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import {
|
||||||
|
fetchDataQuality,
|
||||||
|
runDataQualityCheck,
|
||||||
|
fetchIngestFailures,
|
||||||
|
retryIngestFailure,
|
||||||
|
} from '../dal'
|
||||||
|
import type { IngestFailureItem, DataQualityCheckItem } from '../dal'
|
||||||
|
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
|
||||||
|
|
||||||
|
export default function DataPipelinePage() {
|
||||||
|
const [quality, setQuality] = useState<{ failures: IngestFailureItem[]; checks: DataQualityCheckItem[] } | null>(null)
|
||||||
|
const [failures, setFailures] = useState<IngestFailureItem[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [running, setRunning] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [notice, setNotice] = useState<{ ok: boolean; text: string } | null>(null)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const [q, f] = await Promise.all([fetchDataQuality(), fetchIngestFailures()])
|
||||||
|
setQuality(q)
|
||||||
|
setFailures(f)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '加载失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
const handleRunCheck = async () => {
|
||||||
|
setRunning(true)
|
||||||
|
setNotice(null)
|
||||||
|
try {
|
||||||
|
const res = await runDataQualityCheck()
|
||||||
|
const failed = res.checks.filter(c => !c.passed)
|
||||||
|
setNotice({
|
||||||
|
ok: failed.length === 0,
|
||||||
|
text: failed.length === 0
|
||||||
|
? '数据质量检查通过'
|
||||||
|
: `检查完成: ${failed.length} 项未通过`,
|
||||||
|
})
|
||||||
|
await load()
|
||||||
|
} catch {
|
||||||
|
setNotice({ ok: false, text: '质量检查执行失败' })
|
||||||
|
} finally {
|
||||||
|
setRunning(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRetry = async (id: number) => {
|
||||||
|
setNotice(null)
|
||||||
|
try {
|
||||||
|
const res = await retryIngestFailure(id)
|
||||||
|
setNotice({ ok: true, text: res.message })
|
||||||
|
await load()
|
||||||
|
} catch {
|
||||||
|
setNotice({ ok: false, text: '重试操作失败' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingFailures = failures.filter(f => f.status === 'pending' || f.status === 'retrying')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<SectionHeader
|
||||||
|
title="数据管线"
|
||||||
|
description="采集失败重试、数据质量检查与监控"
|
||||||
|
action={
|
||||||
|
<button onClick={handleRunCheck} disabled={running} className="btn btn-sm">
|
||||||
|
{running ? <><Spinner /> 检查中</> : '运行质量检查'}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{notice && (
|
||||||
|
<Alert kind={notice.ok ? 'ok' : 'error'} title={notice.text} onClose={() => setNotice(null)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <Alert kind="error" title="加载失败" message={error} onClose={() => setError(null)} />}
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="flex justify-center py-12"><Spinner /></div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && (
|
||||||
|
<>
|
||||||
|
{/* 采集失败记录 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
title="采集失败记录"
|
||||||
|
description={pendingFailures.length > 0 ? `${pendingFailures.length} 条待处理` : '暂无待处理失败记录'}
|
||||||
|
/>
|
||||||
|
<CardBody className="px-0 sm:px-0">
|
||||||
|
{failures.length === 0 ? (
|
||||||
|
<p className="py-8 text-center text-xs text-ink-400">暂无采集失败记录</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[640px] text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-ink-200 text-left text-ink-500">
|
||||||
|
<th className="px-4 py-2 font-medium">来源</th>
|
||||||
|
<th className="px-4 py-2 font-medium">实体类型</th>
|
||||||
|
<th className="px-4 py-2 font-medium">错误类型</th>
|
||||||
|
<th className="px-4 py-2 font-medium">重试次数</th>
|
||||||
|
<th className="px-4 py-2 font-medium">状态</th>
|
||||||
|
<th className="px-4 py-2 font-medium">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{failures.map(f => (
|
||||||
|
<tr key={f.id} className="border-b border-ink-100 hover:bg-paper-100">
|
||||||
|
<td className="px-4 py-2 text-xs text-ink-700">{f.source}</td>
|
||||||
|
<td className="px-4 py-2 text-xs text-ink-600">{f.entity_type}</td>
|
||||||
|
<td className="px-4 py-2 text-xs text-ink-600">{f.error_type}</td>
|
||||||
|
<td className="px-4 py-2 text-xs tabular-nums text-ink-500">{f.retry_count}</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<Badge status={f.status === 'resolved' ? 'success' : f.status === 'pending' ? 'warning' : 'info'}>
|
||||||
|
{f.status}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
{(f.status === 'pending' || f.status === 'retrying') && (
|
||||||
|
<button onClick={() => handleRetry(f.id)} className="btn btn-sm">
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 数据质量检查 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader title="数据质量检查" description="最近 20 条检查结果" />
|
||||||
|
<CardBody className="px-0 sm:px-0">
|
||||||
|
{quality?.checks.length === 0 ? (
|
||||||
|
<p className="py-8 text-center text-xs text-ink-400">暂无质量检查记录,点击右上角「运行质量检查」触发</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[560px] text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-ink-200 text-left text-ink-500">
|
||||||
|
<th className="px-4 py-2 font-medium">检查项</th>
|
||||||
|
<th className="px-4 py-2 font-medium">实体</th>
|
||||||
|
<th className="px-4 py-2 font-medium">结果</th>
|
||||||
|
<th className="px-4 py-2 font-medium">严重度</th>
|
||||||
|
<th className="px-4 py-2 font-medium">时间</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{quality?.checks.map(c => (
|
||||||
|
<tr key={c.id} className="border-b border-ink-100 hover:bg-paper-100">
|
||||||
|
<td className="px-4 py-2 text-xs text-ink-700">{c.check_name}</td>
|
||||||
|
<td className="px-4 py-2 text-xs text-ink-600">{c.entity_type}</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<Badge status={c.passed ? 'success' : 'error'}>
|
||||||
|
{c.passed ? '通过' : '未通过'}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<Badge status={c.severity === 'warning' ? 'warning' : c.severity === 'error' ? 'error' : 'info'}>
|
||||||
|
{c.severity}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-2xs text-ink-400">
|
||||||
|
{c.checked_at ? new Date(c.checked_at).toLocaleString('zh-CN', { hour12: false }) : '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,11 +3,14 @@ import {
|
|||||||
fetchDataSourceStatuses,
|
fetchDataSourceStatuses,
|
||||||
fetchIngestStatus,
|
fetchIngestStatus,
|
||||||
fetchAdminStats,
|
fetchAdminStats,
|
||||||
|
fetchKeyRingStatus,
|
||||||
|
resetKeyRingCooldown,
|
||||||
updateSetting,
|
updateSetting,
|
||||||
clearSetting,
|
clearSetting,
|
||||||
testDataSourceConnection,
|
testDataSourceConnection,
|
||||||
} from '../dal'
|
} from '../dal'
|
||||||
import type { DataSourceStatus, DataSourceTestResult, IngestSourceStatus, AdminStats } from '../types'
|
import type { DataSourceStatus, DataSourceTestResult, IngestSourceStatus, AdminStats } from '../types'
|
||||||
|
import type { KeyRingStatusResponse } from '../dal'
|
||||||
import SettingRow from '../SettingRow'
|
import SettingRow from '../SettingRow'
|
||||||
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components'
|
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components'
|
||||||
|
|
||||||
@@ -34,6 +37,8 @@ export default function DataSourcesPage() {
|
|||||||
const [editingKey, setEditingKey] = useState<string | null>(null)
|
const [editingKey, setEditingKey] = useState<string | null>(null)
|
||||||
const [busyKey, setBusyKey] = useState<string | null>(null)
|
const [busyKey, setBusyKey] = useState<string | null>(null)
|
||||||
const [rowNotice, setRowNotice] = useState<{ key: string; ok: boolean; text: string } | null>(null)
|
const [rowNotice, setRowNotice] = useState<{ key: string; ok: boolean; text: string } | null>(null)
|
||||||
|
const [keyRing, setKeyRing] = useState<KeyRingStatusResponse | null>(null)
|
||||||
|
const [ringLoading, setRingLoading] = useState(false)
|
||||||
|
|
||||||
const loadSources = useCallback(async () => {
|
const loadSources = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -70,11 +75,24 @@ export default function DataSourcesPage() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Key Ring 状态(只读)
|
||||||
|
const loadKeyRing = useCallback(async () => {
|
||||||
|
setRingLoading(true)
|
||||||
|
try {
|
||||||
|
setKeyRing(await fetchKeyRingStatus())
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
} finally {
|
||||||
|
setRingLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSources()
|
loadSources()
|
||||||
loadIngest()
|
loadIngest()
|
||||||
loadStats()
|
loadStats()
|
||||||
}, [loadSources, loadIngest, loadStats])
|
loadKeyRing()
|
||||||
|
}, [loadSources, loadIngest, loadStats, loadKeyRing])
|
||||||
|
|
||||||
async function handleTest(sourceName: string) {
|
async function handleTest(sourceName: string) {
|
||||||
setTestingSource(sourceName)
|
setTestingSource(sourceName)
|
||||||
@@ -160,6 +178,17 @@ export default function DataSourcesPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleResetCooldown() {
|
||||||
|
if (!window.confirm('确定重置所有 key 的冷却状态?这可能使被限流的 key 立即恢复请求。')) return
|
||||||
|
try {
|
||||||
|
const res = await resetKeyRingCooldown()
|
||||||
|
setKeyRing(res.stats)
|
||||||
|
setRowNotice({ key: "__ring", ok: true, text: res.message })
|
||||||
|
} catch (err) {
|
||||||
|
setRowNotice({ key: "__ring", ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '重置失败' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -252,6 +281,57 @@ export default function DataSourcesPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* API Key 轮换环状态 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
title="API Key 轮换环"
|
||||||
|
description={keyRing?.has_multiple
|
||||||
|
? `已配置 ${keyRing.total} 个 key,遇到限流(429)自动切换;冷却 ${keyRing.cooldown_seconds}s`
|
||||||
|
: '当前仅 1 个 key,无法轮换。建议配置多个 key 以提高限流容忍度'
|
||||||
|
}
|
||||||
|
action={
|
||||||
|
<button
|
||||||
|
onClick={handleResetCooldown}
|
||||||
|
disabled={ringLoading}
|
||||||
|
className="btn-sm btn-outline"
|
||||||
|
>
|
||||||
|
重置冷却
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<CardBody>
|
||||||
|
{ringLoading && !keyRing ? (
|
||||||
|
<SkeletonBlock className="h-10 w-full" />
|
||||||
|
) : keyRing && keyRing.total > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{keyRing.keys.map((k, i) => {
|
||||||
|
const isBlocked = k.blocked_remaining > 0
|
||||||
|
return (
|
||||||
|
<div key={i} className={`flex items-center justify-between gap-3 border-b border-ink-100 py-2 last:border-b-0 ${isBlocked ? 'opacity-70' : ''}`}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`inline-block h-2 w-2 rounded-full ${isBlocked ? 'bg-amber-500' : 'bg-emerald-500'}`} />
|
||||||
|
<span className="font-mono text-xs text-ink-700">{k.masked}</span>
|
||||||
|
{i === keyRing.active_index && (
|
||||||
|
<span className="rounded bg-ink-900 px-1.5 py-0.5 text-2xs text-paper-500">当前</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className={`text-2xs tabular-nums ${isBlocked ? 'text-amber-600' : 'text-ink-400'}`}>
|
||||||
|
{isBlocked ? `冷却中 ${k.blocked_remaining.toFixed(0)}s` : '可用'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-ink-400">暂无 key 配置</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-3 border-l-2 border-ink-300 pl-3 text-2xs leading-relaxed text-ink-500">
|
||||||
|
在「Bzzoiro」配置项中用<b>逗号 / 分号 / 换行</b>分隔多个 key 即可启用轮换。遇到 429 自动标记当前 key 为冷却并立即切换到下一个 key;
|
||||||
|
全部 key 冷却时等待最早恢复的 key。「重置冷却」可紧急恢复所有 key。
|
||||||
|
</p>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* 近期活动统计(只读) */}
|
{/* 近期活动统计(只读) */}
|
||||||
{stats && stats.predictions && (
|
{stats && stats.predictions && (
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { fetchEvalSummary, fetchLeagues } from '../dal'
|
import { fetchEvalSummary, fetchLeagues } from '../dal'
|
||||||
import type { EvalSummary } from '../types'
|
import type { EvalSummary } from '../types'
|
||||||
import { Card, CardBody, CardHeader, StatCard, Badge, DataTable, Alert, Spinner, EmptyState } from '../components'
|
import { Card, CardBody, CardHeader, StatCard, Badge, DataTable, Alert, Spinner, EmptyText } from '../components'
|
||||||
|
|
||||||
interface Filters {
|
interface Filters {
|
||||||
provider: string
|
provider: string
|
||||||
@@ -155,7 +155,7 @@ export default function EvalPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader
|
<CardHeader
|
||||||
title="准确率对比"
|
title="准确率对比"
|
||||||
description="按 provider × 模型 × prompt_version 聚合,仅统计有效预测"
|
description={!loading && summary.length > 0 ? `共 ${summary.length} 组` : "按 provider × 模型 × prompt_version 聚合,仅统计有效预测"}
|
||||||
/>
|
/>
|
||||||
<CardBody>
|
<CardBody>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -163,8 +163,9 @@ export default function EvalPage() {
|
|||||||
<Spinner />
|
<Spinner />
|
||||||
</div>
|
</div>
|
||||||
) : summary.length === 0 ? (
|
) : summary.length === 0 ? (
|
||||||
<EmptyState text="暂无评估数据,请调整筛选条件或先完成预测与结算" />
|
<EmptyText text="暂无评估数据,请调整筛选条件或先完成预测与结算" />
|
||||||
) : (
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={[
|
columns={[
|
||||||
{ key: 'provider', label: '提供商' },
|
{ key: 'provider', label: '提供商' },
|
||||||
@@ -202,6 +203,7 @@ export default function EvalPage() {
|
|||||||
rowKey={(row: any) => `${row.provider}-${row.model}-${row.prompt_version ?? ''}`}
|
rowKey={(row: any) => `${row.provider}-${row.model}-${row.prompt_version ?? ''}`}
|
||||||
emptyText="暂无评估数据"
|
emptyText="暂无评估数据"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ export default function LLMConfigPage() {
|
|||||||
{testing ? (<><Spinner /> 测试中</>) : '测试 LLM 连接'}
|
{testing ? (<><Spinner /> 测试中</>) : '测试 LLM 连接'}
|
||||||
</button>
|
</button>
|
||||||
<p className="mt-2 text-center text-2xs text-ink-400">
|
<p className="mt-2 text-center text-2xs text-ink-400">
|
||||||
测试会真实调用一次单次模式预测,产生 LLM 费用。
|
测试会真实调用一次 LLM 预测,产生费用。
|
||||||
</p>
|
</p>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -52,6 +52,24 @@ export default function LogsPage() {
|
|||||||
load()
|
load()
|
||||||
}, [load])
|
}, [load])
|
||||||
|
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
const userScrolledUp = useRef(false)
|
||||||
|
|
||||||
|
// 检测用户是否向上滚动过
|
||||||
|
const handleScroll = () => {
|
||||||
|
const el = scrollRef.current
|
||||||
|
if (!el) return
|
||||||
|
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 50
|
||||||
|
userScrolledUp.current = !atBottom
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载后自动滚动到底部(仅当用户未向上滚动时)
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoRefresh && !userScrolledUp.current && scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||||
|
}
|
||||||
|
}, [entries, autoRefresh])
|
||||||
|
|
||||||
// 自动刷新
|
// 自动刷新
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (timerRef.current) clearInterval(timerRef.current)
|
if (timerRef.current) clearInterval(timerRef.current)
|
||||||
@@ -123,7 +141,7 @@ export default function LogsPage() {
|
|||||||
) : entries.length === 0 ? (
|
) : entries.length === 0 ? (
|
||||||
<p className="py-10 text-center text-xs text-ink-400">暂无匹配的日志</p>
|
<p className="py-10 text-center text-xs text-ink-400">暂无匹配的日志</p>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div ref={scrollRef} onScroll={handleScroll} className="max-h-[60vh] overflow-y-auto">
|
||||||
{entries.map((e, i) => {
|
{entries.map((e, i) => {
|
||||||
const badge = LEVEL_BADGE[e.level] ?? { status: 'info' as const, text: e.level }
|
const badge = LEVEL_BADGE[e.level] ?? { status: 'info' as const, text: e.level }
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,316 @@
|
|||||||
|
/**
|
||||||
|
* Admin 后台 - 预测历史页面(报刊风)
|
||||||
|
*
|
||||||
|
* 功能:
|
||||||
|
* - 查看预测记录列表(支持状态筛选)
|
||||||
|
* - 实际比分自动从比赛赛果填充
|
||||||
|
* - 赛后一键结算,供准确率评估
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { fetchPredictions, settlePrediction } from '../dal'
|
||||||
|
import type { Prediction } from '../types'
|
||||||
|
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
|
||||||
|
import TeamSideTag from '../../components/TeamSideTag'
|
||||||
|
|
||||||
|
const OUTCOME_LABEL: Record<string, string> = { '1': '主胜', X: '平局', '2': '客胜' }
|
||||||
|
|
||||||
|
function fmtDate(s?: string | null): string {
|
||||||
|
if (!s) return '—'
|
||||||
|
const d = new Date(s)
|
||||||
|
return isNaN(d.getTime()) ? s : `${d.getMonth() + 1}/${d.getDate()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PredictionHistoryPage() {
|
||||||
|
const [predictions, setPredictions] = useState<Prediction[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [filter, setFilter] = useState<'all' | 'settled' | 'unsettled'>('all')
|
||||||
|
const [expandedId, setExpandedId] = useState<number | null>(null)
|
||||||
|
const [settlingId, setSettlingId] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const list = await fetchPredictions(200)
|
||||||
|
setPredictions(list)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '加载失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
const handleSettle = async (p: Prediction) => {
|
||||||
|
const match = (p as any).match
|
||||||
|
if (!match || match.home_goals == null || match.away_goals == null) return
|
||||||
|
setSettlingId(p.id)
|
||||||
|
try {
|
||||||
|
await settlePrediction(p.id, match.home_goals, match.away_goals)
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('结算失败:', err)
|
||||||
|
} finally {
|
||||||
|
setSettlingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = predictions.filter(p => {
|
||||||
|
if (filter === 'settled') return p.settled
|
||||||
|
if (filter === 'unsettled') return !p.settled
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
const unsettledCount = predictions.filter(p => !p.settled).length
|
||||||
|
const settledWithScore = predictions.filter(p => p.settled && p.actual_home_goals != null && p.actual_away_goals != null)
|
||||||
|
const hitCount = settledWithScore.filter(p => {
|
||||||
|
const actual = p.actual_home_goals! > p.actual_away_goals! ? '1' : p.actual_home_goals! < p.actual_away_goals! ? '2' : 'X'
|
||||||
|
return actual === p.pred_1x2
|
||||||
|
}).length
|
||||||
|
const accuracy = settledWithScore.length > 0 ? Math.round(hitCount / settledWithScore.length * 100) : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<SectionHeader
|
||||||
|
title="预测历史"
|
||||||
|
description={`共 ${predictions.length} 条记录${unsettledCount > 0 ? `, ${unsettledCount} 条待结算` : ''}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 统计概览 */}
|
||||||
|
{settledWithScore.length > 0 && (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<Card>
|
||||||
|
<CardBody className="text-center">
|
||||||
|
<p className="text-2xl font-bold text-ink-900">{settledWithScore.length}</p>
|
||||||
|
<p className="text-xs text-ink-500">已结算</p>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardBody className="text-center">
|
||||||
|
<p className="text-2xl font-bold text-emerald-700">{hitCount}</p>
|
||||||
|
<p className="text-xs text-ink-500">命中</p>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardBody className="text-center">
|
||||||
|
<p className="text-2xl font-bold text-press">{accuracy}%</p>
|
||||||
|
<p className="text-xs text-ink-500">1X2 准确率</p>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 筛选 */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{([
|
||||||
|
{ v: 'all', label: '全部' },
|
||||||
|
{ v: 'unsettled', label: '待结算' },
|
||||||
|
{ v: 'settled', label: '已结算' },
|
||||||
|
] as const).map(opt => (
|
||||||
|
<button
|
||||||
|
key={opt.v}
|
||||||
|
onClick={() => setFilter(opt.v)}
|
||||||
|
className={`btn btn-sm ${filter === opt.v ? 'btn-solid' : ''}`}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="flex justify-center py-12"><Spinner /></div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert kind="error" title="加载失败" message={error} onClose={() => setError(null)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && filtered.length === 0 && (
|
||||||
|
<div className="empty-state">
|
||||||
|
<p className="empty-state-title">暂无预测记录</p>
|
||||||
|
<p className="empty-state-sub">在主站比赛列表点击「预测」按钮生成预测记录</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 预测列表 */}
|
||||||
|
{!loading && !error && filtered.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardBody className="px-0 sm:px-0">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[700px] text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-ink-200 text-left text-ink-500">
|
||||||
|
<th className="px-4 py-2 font-medium">比赛日</th>
|
||||||
|
<th className="px-4 py-2 font-medium">比赛</th>
|
||||||
|
<th className="px-4 py-2 font-medium">预测</th>
|
||||||
|
<th className="px-4 py-2 font-medium">实际</th>
|
||||||
|
<th className="px-4 py-2 font-medium">1X2</th>
|
||||||
|
<th className="px-4 py-2 font-medium">状态</th>
|
||||||
|
<th className="px-4 py-2 font-medium">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filtered.map(p => {
|
||||||
|
const match = (p as any).match
|
||||||
|
const matchDate = match?.match_date
|
||||||
|
const homeName = match?.home_team_zh || match?.home_team || '?'
|
||||||
|
const awayName = match?.away_team_zh || match?.away_team || '?'
|
||||||
|
const actualHome = match?.home_goals
|
||||||
|
const actualAway = match?.away_goals
|
||||||
|
const hasActual = actualHome != null && actualAway != null
|
||||||
|
const isExpanded = expandedId === p.id
|
||||||
|
const predHit = p.settled && hasActual
|
||||||
|
? (actualHome! > actualAway! ? '1' : actualHome! < actualAway! ? '2' : 'X') === p.pred_1x2
|
||||||
|
: null
|
||||||
|
return (
|
||||||
|
<tr key={p.id} className="border-b border-ink-100 hover:bg-paper-100">
|
||||||
|
<td className="px-4 py-3 text-2xs text-ink-400">{fmtDate(matchDate)}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-1 text-xs text-ink-800">
|
||||||
|
<TeamSideTag side="home" />
|
||||||
|
<span className="truncate max-w-[100px]">{homeName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-xs text-ink-600 mt-0.5">
|
||||||
|
<TeamSideTag side="away" />
|
||||||
|
<span className="truncate max-w-[100px]">{awayName}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-serif font-bold text-ink-900">
|
||||||
|
{p.pred_home_goals != null && p.pred_away_goals != null
|
||||||
|
? `${p.pred_home_goals} : ${p.pred_away_goals}` : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{hasActual ? (
|
||||||
|
<span className={`font-serif font-bold ${p.settled ? 'text-ink-900' : 'text-ink-400'}`}>
|
||||||
|
{actualHome} : {actualAway}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-2xs text-ink-300">暂无赛果</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{p.pred_1x2 ? (
|
||||||
|
<span className={`inline-block border px-1.5 py-0.5 text-2xs ${
|
||||||
|
predHit === true ? 'border-emerald-300 text-emerald-700 bg-emerald-50' :
|
||||||
|
predHit === false ? 'border-press text-press bg-press-wash' :
|
||||||
|
'border-ink-200 text-ink-600'
|
||||||
|
}`}>
|
||||||
|
{OUTCOME_LABEL[p.pred_1x2] ?? p.pred_1x2}
|
||||||
|
{predHit === true && ' ✓'}
|
||||||
|
{predHit === false && ' ✗'}
|
||||||
|
</span>
|
||||||
|
) : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{p.settled ? (
|
||||||
|
<Badge status="success">已结算</Badge>
|
||||||
|
) : hasActual ? (
|
||||||
|
<Badge status="info">可结算</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge status="warning">待赛果</Badge>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{!p.settled && hasActual && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleSettle(p)}
|
||||||
|
disabled={settlingId === p.id}
|
||||||
|
className="btn btn-sm btn-solid"
|
||||||
|
>
|
||||||
|
{settlingId === p.id ? <Spinner /> : '结算'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setExpandedId(isExpanded ? null : p.id)}
|
||||||
|
className="btn btn-sm"
|
||||||
|
>
|
||||||
|
{isExpanded ? '收起' : '详情'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 展开详情 */}
|
||||||
|
{expandedId && (() => {
|
||||||
|
const p = predictions.find(pr => pr.id === expandedId)
|
||||||
|
if (!p) return null
|
||||||
|
const match = (p as any).match
|
||||||
|
const reports = p.agent_outputs ?? []
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader title="预测详情" />
|
||||||
|
<CardBody className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-ink-700">
|
||||||
|
<span>{fmtDate(match?.match_date)}</span>
|
||||||
|
<span className="text-ink-300">|</span>
|
||||||
|
<TeamSideTag side="home" />
|
||||||
|
<span>{match?.home_team_zh || match?.home_team || '?'}</span>
|
||||||
|
<span className="text-ink-400">vs</span>
|
||||||
|
<TeamSideTag side="away" />
|
||||||
|
<span>{match?.away_team_zh || match?.away_team || '?'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<div className="border-t-2 border-ink-900 pt-3">
|
||||||
|
<p className="text-2xs text-ink-400">预测比分</p>
|
||||||
|
<p className="mt-1 font-serif text-xl font-bold text-ink-900">
|
||||||
|
{p.pred_home_goals != null && p.pred_away_goals != null
|
||||||
|
? `${p.pred_home_goals} : ${p.pred_away_goals}` : '—'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="border-t-2 border-ink-900 pt-3">
|
||||||
|
<p className="text-2xs text-ink-400">实际比分</p>
|
||||||
|
<p className="mt-1 font-serif text-xl font-bold text-ink-900">
|
||||||
|
{match?.home_goals != null && match?.away_goals != null
|
||||||
|
? `${match.home_goals} : ${match.away_goals}` : '—'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="border-t-2 border-ink-900 pt-3">
|
||||||
|
<p className="text-2xs text-ink-400">置信度</p>
|
||||||
|
<p className="mt-1 font-serif text-xl font-bold text-ink-900">
|
||||||
|
{p.subjective_confidence != null ? `${(p.subjective_confidence * 100).toFixed(0)}%` : '—'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{p.reasoning && (
|
||||||
|
<div>
|
||||||
|
<h4 className="section-head mb-2">终裁意见</h4>
|
||||||
|
<blockquote className="border-l-2 border-press pl-4">
|
||||||
|
<p className="whitespace-pre-wrap font-serif text-sm leading-loose text-ink-700">{p.reasoning}</p>
|
||||||
|
</blockquote>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{reports.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="section-head mb-2">专家意见</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{reports.map((r) => (
|
||||||
|
<div key={r.agent} className="border-b border-ink-100 pb-2 last:border-b-0">
|
||||||
|
<p className="text-xs font-medium text-ink-700">{r.agent}</p>
|
||||||
|
<p className="mt-0.5 text-xs text-ink-500">{r.analysis || '—'}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import { triggerPrediction, fetchPredictions, fetchMatches, settlePrediction } f
|
|||||||
import type { Match, Prediction } from '../types'
|
import type { Match, Prediction } from '../types'
|
||||||
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
|
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
|
||||||
import { teamSidePrefix } from '../../components/TeamSideTag'
|
import { teamSidePrefix } from '../../components/TeamSideTag'
|
||||||
|
import { AgentWeightsBar } from '../components'
|
||||||
|
|
||||||
const AGENT_LABELS: Record<string, string> = {
|
const AGENT_LABELS: Record<string, string> = {
|
||||||
h2h: '历史交锋分析专家',
|
h2h: '历史交锋分析专家',
|
||||||
@@ -290,8 +291,14 @@ export default function PredictionsPage() {
|
|||||||
<Badge status="success">已结算</Badge>
|
<Badge status="success">已结算</Badge>
|
||||||
) : (
|
) : (
|
||||||
<Badge status="pending">未结算</Badge>
|
<Badge status="pending">未结算</Badge>
|
||||||
)}
|
)}
|
||||||
<span className="text-2xs tabular-nums text-ink-400">{fmtTime(p.created_at)}</span>
|
{p.status === 'degraded' && (
|
||||||
|
<Badge status="error">降级·仅供参考</Badge>
|
||||||
|
)}
|
||||||
|
{p.status === 'failed' && (
|
||||||
|
<Badge status="error">预测失败</Badge>
|
||||||
|
)}
|
||||||
|
<span className="text-2xs tabular-nums text-ink-400">{fmtTime(p.created_at)}</span>
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 20 20"
|
viewBox="0 0 20 20"
|
||||||
className="h-3 w-3 self-center text-ink-300 transition-transform group-open:rotate-90"
|
className="h-3 w-3 self-center text-ink-300 transition-transform group-open:rotate-90"
|
||||||
@@ -317,6 +324,10 @@ export default function PredictionsPage() {
|
|||||||
</blockquote>
|
</blockquote>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{p.agent_weights && Object.keys(p.agent_weights).length > 0 && (
|
||||||
|
<AgentWeightsBar weights={p.agent_weights} okCount={okAgents.length} />
|
||||||
|
)}
|
||||||
|
|
||||||
{p.agent_outputs && p.agent_outputs.length > 0 && (
|
{p.agent_outputs && p.agent_outputs.length > 0 && (
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-1">
|
||||||
{p.agent_outputs.map((a, i) => (
|
{p.agent_outputs.map((a, i) => (
|
||||||
|
|||||||
@@ -0,0 +1,496 @@
|
|||||||
|
/**
|
||||||
|
* Admin 后台 - 统一设置页(报刊风)
|
||||||
|
*
|
||||||
|
* 合并原「数据源」「LLM 配置」「系统配置」三页:
|
||||||
|
* 1. 数据源 — bzzoiro API Key + Key Ring 状态
|
||||||
|
* 2. LLM — 连接配置 + 使用统计 + 专家独立配置
|
||||||
|
* 3. 认证 — 修改密码
|
||||||
|
* 4. 系统 — 配置查看 + 修改指南
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import {
|
||||||
|
fetchSettings, updateSetting, clearSetting,
|
||||||
|
testLLMConnection, fetchLLMUsageStats, fetchLLMModels,
|
||||||
|
fetchKeyRingStatus, resetKeyRingCooldown,
|
||||||
|
fetchSchedules, createSchedule, updateSchedule, deleteSchedule, runScheduleNow,
|
||||||
|
} from '../dal'
|
||||||
|
import type { ScheduleItem } from '../dal'
|
||||||
|
import { changePassword, fetchAuthState, UNAUTHORIZED_EVENT } from '../api'
|
||||||
|
import type { LLMUsageStats, DataSourceSetting } from '../types'
|
||||||
|
import type { KeyRingStatusResponse } from '../dal'
|
||||||
|
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components'
|
||||||
|
import SettingRow from '../SettingRow'
|
||||||
|
import AgentLLMCard from '../AgentLLMCard'
|
||||||
|
|
||||||
|
const DATA_SOURCE_KEYS = ['BZZOIRO_KEY', 'BZZOIRO_BASE']
|
||||||
|
const LLM_KEYS = ['LLM_API_KEY', 'LLM_BASE_URL', 'LLM_MODEL']
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const [allSettings, setAllSettings] = 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)
|
||||||
|
|
||||||
|
// LLM
|
||||||
|
const [llmStats, setLlmStats] = useState<LLMUsageStats | null>(null)
|
||||||
|
const [llmLoading, setLlmLoading] = useState(true)
|
||||||
|
const [testing, setTesting] = useState(false)
|
||||||
|
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null)
|
||||||
|
|
||||||
|
// Key Ring
|
||||||
|
const [keyRing, setKeyRing] = useState<KeyRingStatusResponse | null>(null)
|
||||||
|
const [ringLoading, setRingLoading] = useState(false)
|
||||||
|
|
||||||
|
// 密码
|
||||||
|
const [passwordOrigin, setPasswordOrigin] = useState<'db' | 'env' | 'none' | null>(null)
|
||||||
|
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 [schedules, setSchedules] = useState<ScheduleItem[]>([])
|
||||||
|
const [schedulesLoading, setSchedulesLoading] = useState(true)
|
||||||
|
const [scheduleNotice, setScheduleNotice] = useState<{ ok: boolean; text: string } | null>(null)
|
||||||
|
const [scheduleBusyId, setScheduleBusyId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// ── 数据加载 ──
|
||||||
|
const loadSettings = useCallback(async () => {
|
||||||
|
setSettingsLoading(true)
|
||||||
|
try {
|
||||||
|
const all = await fetchSettings()
|
||||||
|
setAllSettings(all)
|
||||||
|
} catch {
|
||||||
|
setAllSettings([])
|
||||||
|
} finally {
|
||||||
|
setSettingsLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadLlmStats = useCallback(async () => {
|
||||||
|
setLlmLoading(true)
|
||||||
|
try {
|
||||||
|
setLlmStats(await fetchLLMUsageStats())
|
||||||
|
} catch {
|
||||||
|
setLlmStats(null)
|
||||||
|
} finally {
|
||||||
|
setLlmLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadKeyRing = useCallback(async () => {
|
||||||
|
setRingLoading(true)
|
||||||
|
try {
|
||||||
|
setKeyRing(await fetchKeyRingStatus())
|
||||||
|
} catch {
|
||||||
|
setKeyRing(null)
|
||||||
|
} finally {
|
||||||
|
setRingLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSettings()
|
||||||
|
loadLlmStats()
|
||||||
|
loadKeyRing()
|
||||||
|
fetchAuthState().then(s => setPasswordOrigin(s.password_origin ?? null)).catch(() => {})
|
||||||
|
fetchSchedules().then(setSchedules).catch(() => []).finally(() => setSchedulesLoading(false))
|
||||||
|
}, [loadSettings, loadLlmStats, loadKeyRing])
|
||||||
|
|
||||||
|
const dataSourceSettings = allSettings.filter(s => DATA_SOURCE_KEYS.includes(s.key))
|
||||||
|
const llmSettings = allSettings.filter(s => LLM_KEYS.includes(s.key))
|
||||||
|
|
||||||
|
// ── 操作 ──
|
||||||
|
const handleSave = async (key: string, value: string) => {
|
||||||
|
setBusyKey(key)
|
||||||
|
setRowNotice(null)
|
||||||
|
try {
|
||||||
|
await updateSetting(key, value)
|
||||||
|
setRowNotice({ key, ok: true, text: '已保存,立即生效' })
|
||||||
|
setEditingKey(null)
|
||||||
|
await loadSettings()
|
||||||
|
if (key === 'BZZOIRO_KEY') await loadKeyRing()
|
||||||
|
} catch (err) {
|
||||||
|
setRowNotice({ key, ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '保存失败' })
|
||||||
|
} finally {
|
||||||
|
setBusyKey(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClear = async (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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 定时任务操作 ──
|
||||||
|
const handleToggleSchedule = async (s: ScheduleItem) => {
|
||||||
|
setScheduleBusyId(s.id)
|
||||||
|
setScheduleNotice(null)
|
||||||
|
try {
|
||||||
|
await updateSchedule(s.id, { enabled: !s.enabled })
|
||||||
|
setScheduleNotice({ ok: true, text: `已${!s.enabled ? '启用' : '禁用'}任务「${s.id}」` })
|
||||||
|
setSchedules(prev => prev.map(x => x.id === s.id ? { ...x, enabled: !x.enabled } : x))
|
||||||
|
} catch {
|
||||||
|
setScheduleNotice({ ok: false, text: '操作失败' })
|
||||||
|
} finally {
|
||||||
|
setScheduleBusyId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRunSchedule = async (s: ScheduleItem) => {
|
||||||
|
setScheduleBusyId(s.id)
|
||||||
|
setScheduleNotice(null)
|
||||||
|
try {
|
||||||
|
await runScheduleNow(s.id)
|
||||||
|
setScheduleNotice({ ok: true, text: `任务「${s.id}」已启动,请在日志页查看进度` })
|
||||||
|
} catch {
|
||||||
|
setScheduleNotice({ ok: false, text: '启动失败' })
|
||||||
|
} finally {
|
||||||
|
setScheduleBusyId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteSchedule = async (s: ScheduleItem) => {
|
||||||
|
setScheduleBusyId(s.id)
|
||||||
|
setScheduleNotice(null)
|
||||||
|
try {
|
||||||
|
await deleteSchedule(s.id)
|
||||||
|
setScheduleNotice({ ok: true, text: `已删除任务「${s.id}」` })
|
||||||
|
setSchedules(prev => prev.filter(x => x.id !== s.id))
|
||||||
|
} catch {
|
||||||
|
setScheduleNotice({ ok: false, text: '删除失败' })
|
||||||
|
} finally {
|
||||||
|
setScheduleBusyId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const detectLLMModels = useCallback(async (): Promise<string[]> => {
|
||||||
|
const r = await fetchLLMModels()
|
||||||
|
if (!r.ok) throw new Error(r.detail)
|
||||||
|
return r.models
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleTest = async () => {
|
||||||
|
setTesting(true)
|
||||||
|
setTestResult(null)
|
||||||
|
try {
|
||||||
|
await testLLMConnection()
|
||||||
|
setTestResult({ success: true, message: 'LLM 连接测试成功' })
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setTestResult({ success: false, message: err instanceof Error ? err.message : 'LLM 连接测试失败' })
|
||||||
|
} finally {
|
||||||
|
setTesting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResetCooldown = async () => {
|
||||||
|
if (!window.confirm('确定重置所有 key 的冷却状态?这可能使被限流的 key 立即恢复请求。')) return
|
||||||
|
try {
|
||||||
|
const res = await resetKeyRingCooldown()
|
||||||
|
setKeyRing(res.stats)
|
||||||
|
setRowNotice({ key: "__ring", ok: true, text: res.message })
|
||||||
|
} catch (err) {
|
||||||
|
setRowNotice({ key: "__ring", ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '重置失败' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChangePassword = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<SectionHeader
|
||||||
|
title="系统设置"
|
||||||
|
description="数据源、LLM、认证等全部配置。保存到数据库并立即生效,优先于 .env。"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── 1. 数据源 ── */}
|
||||||
|
<section>
|
||||||
|
<h2 className="section-head mb-3">数据源</h2>
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
title="Bzzoiro API"
|
||||||
|
description="赛程 / 比分 / 积分榜 / 比赛统计的唯一数据源"
|
||||||
|
action={<button onClick={loadSettings} disabled={settingsLoading} className="btn btn-sm">{settingsLoading ? <><Spinner /> 加载中</> : '刷新'}</button>}
|
||||||
|
/>
|
||||||
|
<CardBody>
|
||||||
|
{settingsLoading ? (
|
||||||
|
<div className="space-3">{dataSourceSettings.map((_, i) => <SkeletonBlock key={i} className="h-9 w-full" />)}</div>
|
||||||
|
) : (
|
||||||
|
dataSourceSettings.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)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
{rowNotice && !rowNotice.key.startsWith('__') && dataSourceSettings.some(s => s.key === rowNotice.key) && (
|
||||||
|
<div className="mt-3"><Alert kind={rowNotice.ok ? 'ok' : 'error'} title={rowNotice.text} /></div>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Key Ring */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
title="API Key 轮换环"
|
||||||
|
description={keyRing?.has_multiple ? `已配置 ${keyRing.total} 个 key,遇限流自动切换` : '当前仅 1 个 key,无法轮换'}
|
||||||
|
action={<button onClick={handleResetCooldown} disabled={ringLoading} className="btn-sm btn-outline">重置冷却</button>}
|
||||||
|
/>
|
||||||
|
<CardBody>
|
||||||
|
{keyRing && keyRing.total > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{keyRing.keys.map((k, i) => {
|
||||||
|
const isBlocked = k.blocked_remaining > 0
|
||||||
|
return (
|
||||||
|
<div key={i} className={`flex items-center justify-between gap-3 border-b border-ink-100 py-2 last:border-b-0 ${isBlocked ? 'opacity-70' : ''}`}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`inline-block h-2 w-2 rounded-full ${isBlocked ? 'bg-amber-500' : 'bg-emerald-500'}`} />
|
||||||
|
<span className="font-mono text-xs text-ink-700">{k.masked}</span>
|
||||||
|
{i === keyRing.active_index && <span className="rounded bg-ink-900 px-1.5 py-0.5 text-2xs text-paper-500">当前</span>}
|
||||||
|
</div>
|
||||||
|
<span className={`text-2xs tabular-nums ${isBlocked ? 'text-amber-600' : 'text-ink-400'}`}>
|
||||||
|
{isBlocked ? `冷却中 ${k.blocked_remaining.toFixed(0)}s` : '可用'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-ink-400">暂无 key 配置</p>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── 2. LLM ── */}
|
||||||
|
<section>
|
||||||
|
<h2 className="section-head mb-3">大语言模型</h2>
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
title="连接配置"
|
||||||
|
description="OpenAI 兼容接口(DeepSeek / 智谱 / 通义等)"
|
||||||
|
action={<button onClick={loadSettings} disabled={settingsLoading} className="btn btn-sm">{settingsLoading ? <><Spinner /> 加载中</> : '刷新'}</button>}
|
||||||
|
/>
|
||||||
|
<CardBody>
|
||||||
|
{settingsLoading ? (
|
||||||
|
<div className="space-y-3">{llmSettings.map((_, i) => <SkeletonBlock key={i} className="h-9 w-full" />)}</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}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
{rowNotice && !rowNotice.key.startsWith('__') && llmSettings.some(s => s.key === rowNotice.key) && (
|
||||||
|
<div className="mt-3"><Alert kind={rowNotice.ok ? 'ok' : 'error'} title={rowNotice.text} /></div>
|
||||||
|
)}
|
||||||
|
{testResult && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<Alert kind={testResult.success ? 'ok' : 'error'} title={testResult.success ? '连接正常' : '连接失败'} message={testResult.success ? undefined : testResult.message} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button onClick={handleTest} disabled={testing} className="btn btn-sm mt-4 w-full">
|
||||||
|
{testing ? <><Spinner /> 测试中</> : '测试 LLM 连接'}
|
||||||
|
</button>
|
||||||
|
<p className="mt-2 text-center text-2xs text-ink-400">测试会真实调用一次 LLM 预测,产生费用。</p>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader title="使用统计" description="从最近预测记录聚合" action={<button onClick={loadLlmStats} disabled={llmLoading} className="btn btn-sm">{llmLoading ? <><Spinner /> 加载中</> : '刷新'}</button>} />
|
||||||
|
<CardBody>
|
||||||
|
{llmLoading ? (
|
||||||
|
<div className="space-y-3"><SkeletonBlock className="h-16 w-full" /><SkeletonBlock className="h-16 w-full" /></div>
|
||||||
|
) : llmStats ? (
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="border-t-2 border-ink-900 pt-3 text-center">
|
||||||
|
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">{llmStats.total_predictions}</div>
|
||||||
|
<div className="mt-1 text-2xs text-ink-400">总预测数</div>
|
||||||
|
</div>
|
||||||
|
<div className="border-t-2 border-ink-900 pt-3 text-center">
|
||||||
|
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">{llmStats.avg_latency_ms > 0 ? `${(llmStats.avg_latency_ms / 1000).toFixed(1)}s` : '—'}</div>
|
||||||
|
<div className="mt-1 text-2xs text-ink-400">平均延迟</div>
|
||||||
|
</div>
|
||||||
|
<div className="border-t-2 border-press pt-3 text-center">
|
||||||
|
<div className="font-serif text-2xl font-bold tabular-nums text-press">{llmStats.success_rate.toFixed(0)}%</div>
|
||||||
|
<div className="mt-1 text-2xs text-ink-400">有效率</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="py-6 text-center text-xs text-ink-400">暂无使用统计数据</p>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6">
|
||||||
|
<AgentLLMCard />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── 3. 认证 ── */}
|
||||||
|
<section>
|
||||||
|
<h2 className="section-head mb-3">登录认证</h2>
|
||||||
|
<Card>
|
||||||
|
<CardHeader title="修改密码" description="密码即会话签名密钥,修改后所有已登录会话失效,需重新登录" />
|
||||||
|
<CardBody>
|
||||||
|
{passwordOrigin && (
|
||||||
|
<p className="mb-3 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="space-y-3">
|
||||||
|
<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={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>
|
||||||
|
</div>
|
||||||
|
{pwdNotice && <Alert kind={pwdNotice.ok ? 'ok' : 'error'} title={pwdNotice.text} />}
|
||||||
|
<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>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── 4. 定时任务 ── */}
|
||||||
|
<section>
|
||||||
|
<h2 className="section-head mb-3">定时任务</h2>
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
title="采集调度"
|
||||||
|
description="配置 cron 表达式定时触发采集任务"
|
||||||
|
action={
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
createSchedule({ id: `schedule-${Date.now()}`, task: 'events', cron: '0 8 * * *', leagues: undefined, enabled: false })
|
||||||
|
.then(() => fetchSchedules().then(setSchedules))
|
||||||
|
}}
|
||||||
|
className="btn btn-sm"
|
||||||
|
>
|
||||||
|
+ 新建
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<CardBody>
|
||||||
|
{scheduleNotice && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<Alert kind={scheduleNotice.ok ? 'ok' : 'error'} title={scheduleNotice.text} onClose={() => setScheduleNotice(null)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{schedulesLoading ? (
|
||||||
|
<SkeletonBlock className="h-10 w-full" />
|
||||||
|
) : schedules.length === 0 ? (
|
||||||
|
<p className="py-6 text-center text-xs text-ink-400">暂无定时任务,点击右上角「+ 新建」创建</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{schedules.map(s => (
|
||||||
|
<div key={s.id} className="flex flex-col gap-2 border-b border-ink-100 py-3 last:border-b-0 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`inline-block h-2 w-2 rounded-full ${s.enabled ? 'bg-emerald-500' : 'bg-ink-300'}`} />
|
||||||
|
<span className="text-xs font-medium text-ink-800">{s.id}</span>
|
||||||
|
<Badge status={s.enabled ? 'success' : 'muted'}>{s.task}</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="font-mono text-2xs text-ink-500">{s.cron}</p>
|
||||||
|
{s.last_run_at && (
|
||||||
|
<p className="text-2xs text-ink-400">
|
||||||
|
上次: {new Date(s.last_run_at).toLocaleString('zh-CN', { hour12: false })}
|
||||||
|
{s.last_status === 'success' ? ' ✓' : s.last_status === 'failed' ? ' ✗' : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleRunSchedule(s)}
|
||||||
|
disabled={scheduleBusyId === s.id}
|
||||||
|
className="btn btn-sm"
|
||||||
|
>
|
||||||
|
{scheduleBusyId === s.id ? <Spinner /> : '立即执行'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggleSchedule(s)}
|
||||||
|
disabled={scheduleBusyId === s.id}
|
||||||
|
className={`btn btn-sm ${s.enabled ? '' : 'btn-solid'}`}
|
||||||
|
>
|
||||||
|
{scheduleBusyId === s.id ? <Spinner /> : (s.enabled ? '禁用' : '启用')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteSchedule(s)}
|
||||||
|
disabled={scheduleBusyId === s.id}
|
||||||
|
className="btn btn-sm btn-danger"
|
||||||
|
>
|
||||||
|
{scheduleBusyId === s.id ? <Spinner /> : '删除'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,14 +9,14 @@ import { Navigate } from 'react-router-dom'
|
|||||||
import AdminLayout from './AdminLayout'
|
import AdminLayout from './AdminLayout'
|
||||||
import Dashboard from './pages/Dashboard'
|
import Dashboard from './pages/Dashboard'
|
||||||
import CollectionPage from './pages/Collection'
|
import CollectionPage from './pages/Collection'
|
||||||
import PredictionsPage from './pages/Predictions'
|
import DataCompletenessPage from './pages/DataCompleteness'
|
||||||
|
import PredictionsPage from './pages/PredictionHistory'
|
||||||
import BacktestPage from './pages/Backtest'
|
import BacktestPage from './pages/Backtest'
|
||||||
import MonitoringPage from './pages/Monitoring'
|
import MonitoringPage from './pages/Monitoring'
|
||||||
import DataSourcesPage from './pages/DataSources'
|
import SettingsPage from './pages/Settings'
|
||||||
import LLMConfigPage from './pages/LLMConfig'
|
|
||||||
import ConfigPage from './pages/Config'
|
|
||||||
import LogsPage from './pages/Logs'
|
import LogsPage from './pages/Logs'
|
||||||
import EvalPage from './pages/EvalPage'
|
import EvalPage from './pages/EvalPage'
|
||||||
|
import DataPipelinePage from './pages/DataPipeline'
|
||||||
|
|
||||||
export const adminRoutes = [
|
export const adminRoutes = [
|
||||||
{
|
{
|
||||||
@@ -25,14 +25,14 @@ export const adminRoutes = [
|
|||||||
children: [
|
children: [
|
||||||
{ index: true, element: <Dashboard /> },
|
{ index: true, element: <Dashboard /> },
|
||||||
{ path: 'collection', element: <CollectionPage /> },
|
{ path: 'collection', element: <CollectionPage /> },
|
||||||
|
{ path: 'data-completeness', element: <DataCompletenessPage /> },
|
||||||
|
{ path: 'data-pipeline', element: <DataPipelinePage /> },
|
||||||
{ path: 'predictions', element: <PredictionsPage /> },
|
{ path: 'predictions', element: <PredictionsPage /> },
|
||||||
{ path: 'backtest', element: <BacktestPage /> },
|
{ path: 'backtest', element: <BacktestPage /> },
|
||||||
{ path: 'monitoring', element: <MonitoringPage /> },
|
{ path: 'monitoring', element: <MonitoringPage /> },
|
||||||
{ path: 'data-sources', element: <DataSourcesPage /> },
|
{ path: 'settings', element: <SettingsPage /> },
|
||||||
{ path: 'llm-config', element: <LLMConfigPage /> },
|
|
||||||
{ path: 'config', element: <ConfigPage /> },
|
|
||||||
{ path: 'logs', element: <LogsPage /> },
|
{ path: 'logs', element: <LogsPage /> },
|
||||||
{ path: 'eval', element: <EvalPage /> },
|
{ path: 'eval', element: <EvalPage /> },
|
||||||
{ path: '*', element: <Navigate to="/admin" replace /> },
|
{ path: '*', element: <Navigate to="/admin" replace /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ export interface Prediction {
|
|||||||
subjective_confidence?: number | null
|
subjective_confidence?: number | null
|
||||||
reasoning?: string | null
|
reasoning?: string | null
|
||||||
agent_outputs?: PredictionAgentOutput[] | null
|
agent_outputs?: PredictionAgentOutput[] | null
|
||||||
|
agent_weights?: Record<string, number> | null
|
||||||
|
status?: 'success' | 'failed' | 'degraded'
|
||||||
created_at: string
|
created_at: string
|
||||||
actual_home_goals?: number | null
|
actual_home_goals?: number | null
|
||||||
actual_away_goals?: number | null
|
actual_away_goals?: number | null
|
||||||
@@ -91,9 +93,10 @@ export interface PredictRequest {
|
|||||||
|
|
||||||
export interface CollectionRequest {
|
export interface CollectionRequest {
|
||||||
status?: string
|
status?: string
|
||||||
source: 'bzzoiro' | 'understat' | 'injuries'
|
source: 'bzzoiro'
|
||||||
leagues?: string[]
|
leagues?: string[]
|
||||||
league?: string
|
task?: 'events' | 'standings' | 'stats' | 'all'
|
||||||
|
limit?: number
|
||||||
season?: string
|
season?: string
|
||||||
date_from?: string
|
date_from?: string
|
||||||
date_to?: string
|
date_to?: string
|
||||||
@@ -318,9 +321,31 @@ export interface MatchDetailOut {
|
|||||||
match_stage: string | null
|
match_stage: string | null
|
||||||
home_xg: number | null
|
home_xg: number | null
|
||||||
away_xg: number | null
|
away_xg: number | null
|
||||||
|
stats: MatchStatsDetail | null
|
||||||
recent_predictions: MatchRecentPrediction[]
|
recent_predictions: MatchRecentPrediction[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** bzzoiro /events/{id}/stats/ 返回的详细比赛统计 */
|
||||||
|
export interface MatchStatsDetail {
|
||||||
|
home_xg: number | null
|
||||||
|
away_xg: number | null
|
||||||
|
home_shots: number | null
|
||||||
|
away_shots: number | null
|
||||||
|
home_shots_on_target: number | null
|
||||||
|
away_shots_on_target: number | null
|
||||||
|
home_corners: number | null
|
||||||
|
away_corners: number | null
|
||||||
|
home_possession: number | null
|
||||||
|
home_yellow_cards: number | null
|
||||||
|
away_yellow_cards: number | null
|
||||||
|
home_red_cards: number | null
|
||||||
|
away_red_cards: number | null
|
||||||
|
home_big_chances: number | null
|
||||||
|
away_big_chances: number | null
|
||||||
|
home_fouls: number | null
|
||||||
|
away_fouls: number | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface TeamRecentMatch {
|
export interface TeamRecentMatch {
|
||||||
match_date: string | null
|
match_date: string | null
|
||||||
home_team: string | null
|
home_team: string | null
|
||||||
@@ -343,4 +368,7 @@ export interface AdminStats {
|
|||||||
last_24h: number
|
last_24h: number
|
||||||
last_7d: number
|
last_7d: number
|
||||||
}
|
}
|
||||||
|
matches?: { total: number; finished: number }
|
||||||
|
stats?: { total: number }
|
||||||
|
standings?: { total: number }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
/**
|
||||||
|
* Admin 后台 - 键盘快捷键: Cmd+K 命令面板
|
||||||
|
*
|
||||||
|
* 提供全局快捷键:
|
||||||
|
* Cmd/Ctrl+K — 打开命令面板(页面跳转)
|
||||||
|
* / — 聚焦搜索(日志页)
|
||||||
|
* r — 刷新当前页面数据(通用)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
|
interface CommandItem {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
group: string
|
||||||
|
action: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCommandPalette(pages: Array<{ to: string; label: string; group: string }>) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const items: CommandItem[] = pages.map(p => ({
|
||||||
|
id: p.to,
|
||||||
|
label: p.label,
|
||||||
|
group: p.group,
|
||||||
|
action: () => { navigate(p.to); setOpen(false) },
|
||||||
|
}))
|
||||||
|
|
||||||
|
const filtered = query
|
||||||
|
? items.filter(i => i.label.toLowerCase().includes(query.toLowerCase()) || i.id.includes(query.toLowerCase()))
|
||||||
|
: items
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
// Cmd/Ctrl+K → 命令面板
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||||
|
e.preventDefault()
|
||||||
|
setOpen(o => !o)
|
||||||
|
setQuery('')
|
||||||
|
}
|
||||||
|
// Escape → 关闭
|
||||||
|
if (e.key === 'Escape' && open) {
|
||||||
|
setOpen(false)
|
||||||
|
setQuery('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', handler)
|
||||||
|
return () => document.removeEventListener('keydown', handler)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
return { open, setOpen, query, setQuery, items: filtered }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 命令面板弹窗 */
|
||||||
|
export function CommandPalette({
|
||||||
|
open,
|
||||||
|
query,
|
||||||
|
setQuery,
|
||||||
|
items,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
query: string
|
||||||
|
setQuery: (q: string) => void
|
||||||
|
items: CommandItem[]
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const [selected, setSelected] = useState(0)
|
||||||
|
|
||||||
|
// 重置选中项当列表变化
|
||||||
|
useEffect(() => { setSelected(0) }, [items.length])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'ArrowDown') { e.preventDefault(); setSelected(s => Math.min(s + 1, items.length - 1)) }
|
||||||
|
if (e.key === 'ArrowUp') { e.preventDefault(); setSelected(s => Math.max(s - 1, 0)) }
|
||||||
|
if (e.key === 'Enter' && items[selected]) { items[selected].action(); onClose() }
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', handler)
|
||||||
|
return () => document.removeEventListener('keydown', handler)
|
||||||
|
}, [open, items, selected, onClose])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
// 按分组聚合
|
||||||
|
const grouped: Record<string, CommandItem[]> = {}
|
||||||
|
for (const item of items) {
|
||||||
|
grouped[item.group] = grouped[item.group] || []
|
||||||
|
grouped[item.group].push(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[60] flex items-start justify-center bg-ink-900/50 p-4 pt-[15vh]"
|
||||||
|
onClick={onClose}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="命令面板"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-lg border border-ink-900 bg-paper-50 shadow-2xl"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* 搜索框 */}
|
||||||
|
<div className="flex items-center gap-2 border-b border-ink-200 px-3 py-2.5">
|
||||||
|
<span className="text-ink-400">⌘</span>
|
||||||
|
<input
|
||||||
|
value={query}
|
||||||
|
onChange={e => setQuery(e.target.value)}
|
||||||
|
placeholder="输入页面名或路径…"
|
||||||
|
autoFocus
|
||||||
|
className="flex-1 bg-transparent text-sm outline-none placeholder:text-ink-400"
|
||||||
|
/>
|
||||||
|
<kbd className="border border-ink-200 px-1.5 py-0.5 text-2xs text-ink-400">ESC</kbd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 结果列表 */}
|
||||||
|
<div className="max-h-[50vh] overflow-y-auto py-2">
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="py-6 text-center text-xs text-ink-400">无匹配页面</p>
|
||||||
|
) : (
|
||||||
|
Object.entries(grouped).map(([group, groupItems]) => (
|
||||||
|
<div key={group}>
|
||||||
|
<p className="px-3 py-1 text-2xs font-medium uppercase tracking-widest text-ink-400">{group}</p>
|
||||||
|
{groupItems.map((item, i) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => { item.action(); onClose() }}
|
||||||
|
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors ${
|
||||||
|
selected === items.indexOf(item) ? 'bg-paper-100 text-press' : 'text-ink-700 hover:bg-paper-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="truncate">{item.label}</span>
|
||||||
|
<span className="ml-auto text-2xs text-ink-400">{item.id}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部提示 */}
|
||||||
|
<div className="flex items-center gap-3 border-t border-ink-200 px-3 py-1.5 text-2xs text-ink-400">
|
||||||
|
<span>↑↓ 导航</span>
|
||||||
|
<span>↵ 跳转</span>
|
||||||
|
<span>ESC 关闭</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+74
-3
@@ -2,6 +2,35 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* 毛体草书(刘建毛草) - 国内 CDN,离线回退粗楷体 */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Liu Jian Mao Cao';
|
||||||
|
src: url('https://cdn.jsdelivr.net/gh/google/fonts@main/ofl/liujianmaocao/LiuJianMaoCao-Regular.ttf') format('truetype');
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 粗毛笔字效果:笔触加粗 + 微描边 */
|
||||||
|
.font-brush {
|
||||||
|
font-weight: 700;
|
||||||
|
-webkit-text-stroke: 0.3px currentColor;
|
||||||
|
text-shadow: 0.5px 0.5px 0 currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 页面内容横向轻扫+淡入 */
|
||||||
|
.page-content-enter {
|
||||||
|
animation: pageSlideIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes pageSlideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
html {
|
html {
|
||||||
-webkit-text-size-adjust: 100%;
|
-webkit-text-size-adjust: 100%;
|
||||||
@@ -58,14 +87,15 @@
|
|||||||
|
|
||||||
/* ── 按钮:方正边框式,悬停反白 ── */
|
/* ── 按钮:方正边框式,悬停反白 ── */
|
||||||
.btn {
|
.btn {
|
||||||
@apply inline-flex items-center justify-center gap-1.5 border border-ink-300 bg-transparent px-3 py-1.5
|
@apply inline-flex items-center justify-center gap-1.5 border border-ink-300 bg-transparent px-3
|
||||||
text-sm text-ink-700 transition-colors duration-150
|
text-sm text-ink-700 transition-colors duration-150
|
||||||
hover:border-ink-900 hover:bg-ink-900 hover:text-paper-50
|
hover:border-ink-900 hover:bg-ink-900 hover:text-paper-50
|
||||||
disabled:cursor-not-allowed disabled:opacity-40
|
disabled:cursor-not-allowed disabled:opacity-40
|
||||||
disabled:hover:border-ink-300 disabled:hover:bg-transparent disabled:hover:text-ink-700;
|
disabled:hover:border-ink-300 disabled:hover:bg-transparent disabled:hover:text-ink-700
|
||||||
|
min-h-[44px] py-1.5;
|
||||||
}
|
}
|
||||||
.btn-sm {
|
.btn-sm {
|
||||||
@apply px-2.5 py-1 text-xs min-h-[36px];
|
@apply px-2.5 text-xs min-h-[36px];
|
||||||
}
|
}
|
||||||
.btn-solid {
|
.btn-solid {
|
||||||
@apply border-ink-900 bg-ink-900 text-paper-50 hover:border-press hover:bg-press;
|
@apply border-ink-900 bg-ink-900 text-paper-50 hover:border-press hover:bg-press;
|
||||||
@@ -74,6 +104,31 @@
|
|||||||
@apply border-press bg-transparent text-press hover:bg-press hover:text-paper-50;
|
@apply border-press bg-transparent text-press hover:bg-press hover:text-paper-50;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 统一空态 ── */
|
||||||
|
.empty-state {
|
||||||
|
@apply border-y border-ink-200 py-12 text-center;
|
||||||
|
}
|
||||||
|
.empty-state-title {
|
||||||
|
@apply font-serif text-sm text-ink-600;
|
||||||
|
}
|
||||||
|
.empty-state-sub {
|
||||||
|
@apply mt-1.5 text-xs text-ink-400;
|
||||||
|
}
|
||||||
|
.empty-state-action {
|
||||||
|
@apply mt-4 inline-flex items-center gap-2 text-2xs text-press hover:text-press-dark transition-colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 统一错误横幅(前台用,后台用 Alert) ── */
|
||||||
|
.error-banner {
|
||||||
|
@apply flex items-start justify-between gap-3 border border-press bg-press-wash px-4 py-3;
|
||||||
|
}
|
||||||
|
.error-banner-title {
|
||||||
|
@apply text-sm font-medium text-press;
|
||||||
|
}
|
||||||
|
.error-banner-detail {
|
||||||
|
@apply mt-0.5 text-xs text-ink-600;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── 表单控件:方正、无圆角 ── */
|
/* ── 表单控件:方正、无圆角 ── */
|
||||||
.field {
|
.field {
|
||||||
@apply border border-ink-300 bg-transparent px-2.5 py-1.5 text-sm text-ink-800
|
@apply border border-ink-300 bg-transparent px-2.5 py-1.5 text-sm text-ink-800
|
||||||
@@ -102,6 +157,22 @@
|
|||||||
.skeleton {
|
.skeleton {
|
||||||
@apply animate-pulse rounded-none bg-ink-200;
|
@apply animate-pulse rounded-none bg-ink-200;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 侧边栏图标:统一样式 + hover过渡到品牌色 ── */
|
||||||
|
.nav-icon {
|
||||||
|
width: 1.125rem;
|
||||||
|
height: 1.125rem;
|
||||||
|
stroke-width: 1.5;
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.5;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
.nav-icon-wrap:hover .nav-icon,
|
||||||
|
.nav-icon-wrap.active .nav-icon {
|
||||||
|
opacity: 1;
|
||||||
|
color: #9E1B1B;
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Admin 后台 ──
|
/* ── Admin 后台 ──
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* 共享 HTTP 客户端(公开站 + Admin 统一)
|
||||||
|
*
|
||||||
|
* 特性:
|
||||||
|
* - 统一超时(默认 30s,可覆盖)
|
||||||
|
* - 统一错误处理(ApiError)
|
||||||
|
* - 401 自动广播(Admin 场景)
|
||||||
|
* - JSON/HTML 容错解析
|
||||||
|
* - 请求竞态防护(可选 signal)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { UNAUTHORIZED_EVENT } from '../admin/api'
|
||||||
|
|
||||||
|
const API_BASE = '/api/v1'
|
||||||
|
const DEFAULT_TIMEOUT = 30_000
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public status: number,
|
||||||
|
public data?: unknown,
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ApiError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RequestOptions {
|
||||||
|
timeoutMs?: number
|
||||||
|
skipAuthHandling?: boolean
|
||||||
|
signal?: AbortSignal
|
||||||
|
method?: string
|
||||||
|
body?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||||
|
const url = path.startsWith('http') ? path : path.startsWith('/') ? path : `${API_BASE}${path}`
|
||||||
|
const { timeoutMs = DEFAULT_TIMEOUT, skipAuthHandling, signal } = options
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||||
|
|
||||||
|
// 如果外部传了 signal,也关联到内部 controller
|
||||||
|
if (signal) {
|
||||||
|
signal.addEventListener('abort', () => controller.abort(), { once: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const rawText = await res.text()
|
||||||
|
let detail: unknown = rawText
|
||||||
|
try {
|
||||||
|
detail = JSON.parse(rawText)
|
||||||
|
} catch {
|
||||||
|
// 非 JSON(如 HTML 错误页),保留原始文本
|
||||||
|
}
|
||||||
|
let message =
|
||||||
|
detail && typeof detail === 'object' && detail !== null && 'detail' in detail
|
||||||
|
? String((detail as { detail: unknown }).detail)
|
||||||
|
: `HTTP ${res.status}: ${res.statusText}`
|
||||||
|
|
||||||
|
if (res.status === 401 && !skipAuthHandling) {
|
||||||
|
message += '\n登录已过期,请重新登录。'
|
||||||
|
window.dispatchEvent(new CustomEvent(UNAUTHORIZED_EVENT))
|
||||||
|
}
|
||||||
|
throw new ApiError(message, res.status, detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 204) {
|
||||||
|
return undefined as T
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json() as Promise<T>
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError) throw err
|
||||||
|
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||||
|
throw new ApiError('请求超时,请稍后重试', 0)
|
||||||
|
}
|
||||||
|
throw new ApiError(
|
||||||
|
err instanceof Error ? err.message : '网络错误,请检查连接',
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const http = {
|
||||||
|
get: <T>(path: string, opts?: RequestOptions) => request<T>(path, { ...opts }),
|
||||||
|
post: <T>(path: string, body?: unknown, opts?: RequestOptions) =>
|
||||||
|
request<T>(path, { ...opts, method: 'POST', body: body ? JSON.stringify(body) : undefined }),
|
||||||
|
put: <T>(path: string, body?: unknown, opts?: RequestOptions) =>
|
||||||
|
request<T>(path, { ...opts, method: 'PUT', body: body ? JSON.stringify(body) : undefined }),
|
||||||
|
delete: <T>(path: string, opts?: RequestOptions) => request<T>(path, { ...opts, method: 'DELETE' }),
|
||||||
|
}
|
||||||
+354
-195
@@ -2,6 +2,8 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
|||||||
import TeamSideTag from '../components/TeamSideTag'
|
import TeamSideTag from '../components/TeamSideTag'
|
||||||
import { fetchMatchDetail, fetchMatchContext } from '../admin/dal'
|
import { fetchMatchDetail, fetchMatchContext } from '../admin/dal'
|
||||||
import type { MatchDetailOut, MatchContextOut, MatchRecentPrediction, TeamRecentMatch } from '../admin/types'
|
import type { MatchDetailOut, MatchContextOut, MatchRecentPrediction, TeamRecentMatch } from '../admin/types'
|
||||||
|
import type { MatchStatsDetail } from '../admin/types'
|
||||||
|
import { http } from '../lib/http'
|
||||||
|
|
||||||
interface Match {
|
interface Match {
|
||||||
id: number
|
id: number
|
||||||
@@ -63,7 +65,7 @@ const AGENT_LABELS: Record<string, string> = {
|
|||||||
form: '近期状态分析专家',
|
form: '近期状态分析专家',
|
||||||
stats: '攻防数据分析专家',
|
stats: '攻防数据分析专家',
|
||||||
home_away: '主客因素分析专家',
|
home_away: '主客因素分析专家',
|
||||||
injuries: '阵容完整性分析专家',
|
standings: '联赛排名分析专家',
|
||||||
}
|
}
|
||||||
|
|
||||||
const LEAGUES = [
|
const LEAGUES = [
|
||||||
@@ -188,16 +190,26 @@ function OutcomeLine({
|
|||||||
export default function Matches() {
|
export default function Matches() {
|
||||||
const [league, setLeague] = useState('E0')
|
const [league, setLeague] = useState('E0')
|
||||||
const [status, setStatus] = useState('scheduled')
|
const [status, setStatus] = useState('scheduled')
|
||||||
const [date, setDate] = useState('') // 日期筛选(空=全部),"today"=今日
|
|
||||||
const [matches, setMatches] = useState<Match[]>([])
|
const [matches, setMatches] = useState<Match[]>([])
|
||||||
const [nextCursor, setNextCursor] = useState<string | null>(null)
|
const [nextCursor, setNextCursor] = useState<string | null>(null)
|
||||||
const [loadingMore, setLoadingMore] = useState(false)
|
const [loadingMore, setLoadingMore] = useState(false)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [showAllUpcoming, setShowAllUpcoming] = useState(false) // 默认仅展示未来 3 天;true 展开全部
|
||||||
|
const [liveMatches, setLiveMatches] = useState<Match[]>([]) // 进行中比赛(顶部独立区块)
|
||||||
|
const [showBackTop, setShowBackTop] = useState(false) // 回到顶部按钮显示态
|
||||||
|
|
||||||
|
// 监听滚动,超过 300px 显示回到顶部按钮
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScroll = () => setShowBackTop(window.scrollY > 300)
|
||||||
|
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||||
|
return () => window.removeEventListener('scroll', handleScroll)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const scrollToTop = () => window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
const [predictingId, setPredictingId] = useState<number | null>(null)
|
const [predictingId, setPredictingId] = useState<number | null>(null)
|
||||||
const [prediction, setPrediction] = useState<Prediction | null>(null)
|
const [prediction, setPrediction] = useState<Prediction | null>(null)
|
||||||
const [predictionFor, setPredictionFor] = useState<Match | null>(null)
|
const [predictionFor, setPredictionFor] = useState<Match | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [mode, setMode] = useState<'single' | 'multi'>('multi')
|
|
||||||
const [expandedId, setExpandedId] = useState<number | null>(null)
|
const [expandedId, setExpandedId] = useState<number | null>(null)
|
||||||
const [detailMap, setDetailMap] = useState<Record<number, MatchDetailOut>>({})
|
const [detailMap, setDetailMap] = useState<Record<number, MatchDetailOut>>({})
|
||||||
const [contextMap, setContextMap] = useState<Record<number, MatchContextOut>>({})
|
const [contextMap, setContextMap] = useState<Record<number, MatchContextOut>>({})
|
||||||
@@ -223,16 +235,12 @@ export default function Matches() {
|
|||||||
const seq = ++loadSeq.current
|
const seq = ++loadSeq.current
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setLoadingMore(false)
|
setLoadingMore(false)
|
||||||
|
setShowAllUpcoming(false) // 切换筛选重置为「未来 3 天」视图
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({ league, status, limit: '50' })
|
const params = new URLSearchParams({ league, status, limit: '50' })
|
||||||
if (date === 'today') params.set('date', todayStr())
|
const data = await http.get<{ items: Match[]; next_cursor: string | null }>(`/matches?${params}`)
|
||||||
else if (date) params.set('date', date)
|
|
||||||
const res = await fetch(`/api/v1/matches?${params}`)
|
|
||||||
if (seq !== loadSeq.current) return // 已有更新的请求,丢弃本次结果
|
if (seq !== loadSeq.current) return // 已有更新的请求,丢弃本次结果
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
||||||
const data = await res.json()
|
|
||||||
if (seq !== loadSeq.current) return
|
|
||||||
setMatches(data.items)
|
setMatches(data.items)
|
||||||
setNextCursor(data.next_cursor ?? null)
|
setNextCursor(data.next_cursor ?? null)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -241,7 +249,7 @@ export default function Matches() {
|
|||||||
} finally {
|
} finally {
|
||||||
if (seq === loadSeq.current) setLoading(false)
|
if (seq === loadSeq.current) setLoading(false)
|
||||||
}
|
}
|
||||||
}, [league, status, date])
|
}, [league, status])
|
||||||
|
|
||||||
// 加载下一页(游标分页)
|
// 加载下一页(游标分页)
|
||||||
const loadMore = async () => {
|
const loadMore = async () => {
|
||||||
@@ -250,12 +258,7 @@ export default function Matches() {
|
|||||||
setLoadingMore(true)
|
setLoadingMore(true)
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({ league, status, limit: '50', cursor: nextCursor })
|
const params = new URLSearchParams({ league, status, limit: '50', cursor: nextCursor })
|
||||||
if (date === 'today') params.set('date', todayStr())
|
const data = await http.get<{ items: Match[]; next_cursor: string | null }>(`/matches?${params}`)
|
||||||
else if (date) params.set('date', date)
|
|
||||||
const res = await fetch(`/api/v1/matches?${params}`)
|
|
||||||
if (seq !== loadSeq.current) return
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
||||||
const data = await res.json()
|
|
||||||
if (seq !== loadSeq.current) return
|
if (seq !== loadSeq.current) return
|
||||||
setMatches(prev => [...prev, ...data.items])
|
setMatches(prev => [...prev, ...data.items])
|
||||||
setNextCursor(data.next_cursor ?? null)
|
setNextCursor(data.next_cursor ?? null)
|
||||||
@@ -267,18 +270,49 @@ export default function Matches() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => { load() }, [load])
|
// 加载进行中比赛(顶部独立区块)
|
||||||
|
const loadLive = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ league, status: 'in_play', limit: '20' })
|
||||||
|
const data = await http.get<{ items: Match[] }>(`/matches?${params}`)
|
||||||
|
setLiveMatches(data.items ?? [])
|
||||||
|
} catch {
|
||||||
|
/* ignore:进行中非核心功能 */
|
||||||
|
}
|
||||||
|
}, [league])
|
||||||
|
|
||||||
/** 今日日期 YYYY-MM-DD(用于「今日」快速筛选) */
|
useEffect(() => { load(); loadLive() }, [load, loadLive])
|
||||||
function todayStr(): string {
|
|
||||||
return new Date().toISOString().slice(0, 10)
|
/** 日期 key 辅助:YYYY-MM-DD(本地时区) */
|
||||||
|
function dateKey(d: Date): string {
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
/** 未来 3 天窗口:今天 00:00 → 第 3 天 00:00(即今天/明天/后天) */
|
||||||
|
function addDays(d: Date, n: number): string {
|
||||||
|
const x = new Date(d)
|
||||||
|
x.setFullYear(x.getFullYear(), x.getMonth(), x.getDate() + n)
|
||||||
|
return dateKey(x)
|
||||||
|
}
|
||||||
|
const todayKey = dateKey(new Date())
|
||||||
|
const windowEnd = addDays(new Date(), 3) // 不含
|
||||||
|
|
||||||
|
/** 比赛是否在未来 3 天内(用于默认视图过滤) */
|
||||||
|
function withinNext3Days(matchDate: string): boolean {
|
||||||
|
const key = toLocalDateKey(matchDate)
|
||||||
|
return key >= todayKey && key < windowEnd
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 按日期分组(YYYY-MM-DD → Match[]),保持时间序 */
|
/** UTC ISO → 本地日期 YYYY-MM-DD(用于分组) */
|
||||||
|
function toLocalDateKey(iso: string): string {
|
||||||
|
const d = new Date(iso)
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按本地日期分组(非 UTC),保持时间序 */
|
||||||
function groupByDate(list: Match[]): Array<[string, Match[]]> {
|
function groupByDate(list: Match[]): Array<[string, Match[]]> {
|
||||||
const map = new Map<string, Match[]>()
|
const map = new Map<string, Match[]>()
|
||||||
for (const m of list) {
|
for (const m of list) {
|
||||||
const key = (m.match_date || '').slice(0, 10)
|
const key = toLocalDateKey(m.match_date)
|
||||||
const arr = map.get(key)
|
const arr = map.get(key)
|
||||||
if (arr) arr.push(m)
|
if (arr) arr.push(m)
|
||||||
else map.set(key, [m])
|
else map.set(key, [m])
|
||||||
@@ -299,25 +333,17 @@ export default function Matches() {
|
|||||||
predictAbort.current = controller
|
predictAbort.current = controller
|
||||||
const timer = setTimeout(() => controller.abort(), 300_000)
|
const timer = setTimeout(() => controller.abort(), 300_000)
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/v1/predict', {
|
const data = await http.post<Prediction>('/predict', { match_id: m.id, mode: 'multi' }, {
|
||||||
method: 'POST',
|
timeoutMs: 300_000,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ match_id: m.id, mode }),
|
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
if (seq !== predictSeq.current) return
|
if (seq !== predictSeq.current) return
|
||||||
if (!res.ok) {
|
|
||||||
const t = await res.text()
|
|
||||||
throw new Error(`HTTP ${res.status}: ${t}`)
|
|
||||||
}
|
|
||||||
const data = await res.json()
|
|
||||||
if (seq !== predictSeq.current) return
|
|
||||||
setPrediction(data)
|
setPrediction(data)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (seq !== predictSeq.current) return
|
if (seq !== predictSeq.current) return
|
||||||
setError(
|
setError(
|
||||||
e instanceof DOMException && e.name === 'AbortError'
|
e instanceof DOMException && e.name === 'AbortError'
|
||||||
? '预测超时(5 分钟),请稍后重试或改用单次模式'
|
? '预测超时(5 分钟),请稍后重试'
|
||||||
: readablePredictError(e),
|
: readablePredictError(e),
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -331,8 +357,22 @@ export default function Matches() {
|
|||||||
return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 只显示时间 HH:mm(日期由分组头承担) */
|
||||||
|
const fmtTime = (s: string) => {
|
||||||
|
const d = new Date(s)
|
||||||
|
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||||
|
}
|
||||||
|
|
||||||
const leagueName = LEAGUES.find(l => l.code === league)?.name ?? league
|
const leagueName = LEAGUES.find(l => l.code === league)?.name ?? league
|
||||||
|
|
||||||
|
/** 未开赛默认仅展示未来 3 天;其余状态展示全部。showAllUpcoming=true 时展开全部。 */
|
||||||
|
const isScheduledView = status === 'scheduled'
|
||||||
|
const visibleMatches = (!isScheduledView || showAllUpcoming)
|
||||||
|
? matches
|
||||||
|
: matches.filter(m => withinNext3Days(m.match_date))
|
||||||
|
// 是否有被折叠的未开赛比赛(用于显示「展开」按钮)
|
||||||
|
const hasHiddenUpcoming = isScheduledView && !showAllUpcoming && matches.length > visibleMatches.length
|
||||||
|
|
||||||
/** 状态/模式一组的文字切换 */
|
/** 状态/模式一组的文字切换 */
|
||||||
const Switch = ({ value, onChange, items }: {
|
const Switch = ({ value, onChange, items }: {
|
||||||
value: string
|
value: string
|
||||||
@@ -370,9 +410,9 @@ export default function Matches() {
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* ── 第二行:状态 / 模式 / 计数 / 刷新 ── */}
|
{/* ── 第二行:状态 / 模式 / 日期 / 计数 / 刷新(小屏 flex-wrap) ── */}
|
||||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 text-xs text-ink-500">
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-ink-500 sm:gap-x-5">
|
||||||
<span className="inline-flex items-center gap-2.5">
|
<span className="inline-flex items-center gap-2">
|
||||||
<span className="text-2xs text-ink-400">状态</span>
|
<span className="text-2xs text-ink-400">状态</span>
|
||||||
<Switch
|
<Switch
|
||||||
value={status}
|
value={status}
|
||||||
@@ -385,59 +425,26 @@ export default function Matches() {
|
|||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span className="inline-flex items-center gap-2.5">
|
|
||||||
<span className="text-2xs text-ink-400">模式</span>
|
|
||||||
<Switch
|
|
||||||
value={mode}
|
|
||||||
onChange={v => setMode(v as 'single' | 'multi')}
|
|
||||||
items={[
|
|
||||||
{ v: 'single', label: '单次', title: '单次调用,快但只有一个模型看全部数据' },
|
|
||||||
{ v: 'multi', label: '多专家', title: '5 个专家并行分析后由终裁汇总,质量更高' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="inline-flex items-center gap-2.5">
|
|
||||||
<span className="text-2xs text-ink-400">日期</span>
|
|
||||||
<span className="inline-flex items-center gap-1">
|
|
||||||
<button
|
|
||||||
onClick={() => setDate(date === 'today' ? '' : 'today')}
|
|
||||||
className={`btn btn-sm px-2 ${date === 'today' ? 'btn-solid' : ''}`}
|
|
||||||
title="只看今日"
|
|
||||||
>今日</button>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={date === 'today' ? todayStr() : date}
|
|
||||||
onChange={e => setDate(e.target.value)}
|
|
||||||
className="field px-1.5 py-1 text-xs"
|
|
||||||
aria-label="按日期筛选"
|
|
||||||
/>
|
|
||||||
{date && (
|
|
||||||
<button onClick={() => setDate('')} className="text-ink-400 hover:text-ink-900" aria-label="清除日期" title="清除">×</button>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="ml-auto inline-flex items-center gap-3">
|
<span className="ml-auto inline-flex items-center gap-3">
|
||||||
<span className="tabular-nums">共 {matches.length} 场</span>
|
<span className="tabular-nums">
|
||||||
|
{isScheduledView && !showAllUpcoming && hasHiddenUpcoming
|
||||||
|
? `未来3天 ${visibleMatches.length} / 共 ${matches.length} 场`
|
||||||
|
: `共 ${visibleMatches.length} 场`}
|
||||||
|
</span>
|
||||||
<button onClick={load} disabled={loading} className="btn btn-sm">
|
<button onClick={load} disabled={loading} className="btn btn-sm">
|
||||||
{loading ? (<><Spinner /> 获取中</>) : '刷新'}
|
{loading ? (<><Spinner /> 获取中</>) : '刷新'}
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── 错误提示 ── */}
|
{/* ── 错误提示(统一 error-banner 样式) ── */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="flex items-start justify-between gap-3 border border-press bg-press-wash px-4 py-3">
|
<div className="error-banner">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-press">请求失败</p>
|
<p className="error-banner-title">请求失败</p>
|
||||||
<p className="mt-0.5 text-xs text-ink-600">{error}</p>
|
<p className="error-banner-detail">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setError(null)} className="text-ink-400 transition-colors hover:text-ink-900" aria-label="关闭">
|
<button onClick={() => setError(null)} className="text-ink-400 transition-colors hover:text-ink-900 text-lg leading-none p-1" aria-label="关闭">×</button>
|
||||||
<svg viewBox="0 0 20 20" className="h-4 w-4" 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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -445,7 +452,6 @@ export default function Matches() {
|
|||||||
{predictionFor && (
|
{predictionFor && (
|
||||||
<PredictModal
|
<PredictModal
|
||||||
match={predictionFor}
|
match={predictionFor}
|
||||||
mode={mode}
|
|
||||||
predicting={predictingId === predictionFor.id}
|
predicting={predictingId === predictionFor.id}
|
||||||
prediction={predictingId === predictionFor.id ? null : prediction}
|
prediction={predictingId === predictionFor.id ? null : prediction}
|
||||||
error={predictingId === predictionFor.id ? null : error}
|
error={predictingId === predictionFor.id ? null : error}
|
||||||
@@ -453,22 +459,69 @@ export default function Matches() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── 进行中比赛(顶部独立区块,仅未开赛视图展示) ── */}
|
||||||
|
{isScheduledView && liveMatches.length > 0 && (
|
||||||
|
<section aria-label="进行中" className="border border-ink-900 bg-paper-100">
|
||||||
|
<div className="flex items-center gap-2 border-b border-ink-900 px-3 py-2">
|
||||||
|
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-press" />
|
||||||
|
<span className="text-xs font-medium tracking-wide text-ink-700">进行中 · 实时比分</span>
|
||||||
|
<span className="text-2xs text-ink-400">{liveMatches.length} 场</span>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-ink-200">
|
||||||
|
{liveMatches.map(m => {
|
||||||
|
const homeName = m.home_team_zh || m.home_team
|
||||||
|
const awayName = m.away_team_zh || m.away_team
|
||||||
|
return (
|
||||||
|
<div key={m.id} className="flex items-center justify-between gap-3 px-3 py-2.5">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className="w-12 shrink-0 text-center font-serif text-lg font-bold tabular-nums text-ink-900">
|
||||||
|
{m.home_goals ?? '-'}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 truncate text-xs text-ink-700">{homeName}</span>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 text-2xs text-ink-400">vs</span>
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className="min-w-0 truncate text-right text-xs text-ink-700">{awayName}</span>
|
||||||
|
<span className="w-12 shrink-0 text-center font-serif text-lg font-bold tabular-nums text-ink-900">
|
||||||
|
{m.away_goals ?? '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── 赛程栏:表格化,行间细线,按日期分组 ── */}
|
{/* ── 赛程栏:表格化,行间细线,按日期分组 ── */}
|
||||||
<section aria-label="赛程">
|
<section aria-label="赛程">
|
||||||
{loading && <SkeletonRows n={4} />}
|
{loading && <SkeletonRows n={4} />}
|
||||||
|
|
||||||
{!loading && matches.length === 0 && (
|
{!loading && visibleMatches.length === 0 && (
|
||||||
<div className="border-y border-ink-200 py-14 text-center">
|
<div className="empty-state">
|
||||||
<p className="font-serif text-sm text-ink-600">本版暂无赛程</p>
|
{matches.length > 0 ? (
|
||||||
<p className="mt-1.5 text-xs text-ink-400">请先通过采集接口导入 {leagueName} 的比赛数据</p>
|
<>
|
||||||
|
<p className="empty-state-title">未来 3 天暂无 {leagueName} 比赛</p>
|
||||||
|
<p className="empty-state-sub">已导入 {matches.length} 场未开赛,点击下方按钮查看</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="empty-state-title">本版暂无赛程</p>
|
||||||
|
<p className="empty-state-sub">请先通过「数据采集」导入 {leagueName} 的比赛数据</p>
|
||||||
|
<a href="/admin/collection" className="empty-state-action">
|
||||||
|
前往数据采集 <span aria-hidden="true">→</span>
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && groupByDate(matches).map(([dateKey, group]) => (
|
{!loading && groupByDate(visibleMatches).map(([dateKey, group]) => (
|
||||||
<div key={dateKey}>
|
<div key={dateKey}>
|
||||||
{/* 日期分组头 */}
|
{/* 日期分组头:sticky 但 z 低于弹窗 z-50 */}
|
||||||
<div className="sticky top-0 z-10 border-y border-ink-200 bg-paper-100 px-2 py-1.5 text-2xs font-medium tracking-wide text-ink-500">
|
<div className="sticky top-0 z-20 border-b border-ink-900 bg-paper-100 px-3 py-2 text-xs font-medium tracking-wide text-ink-600">
|
||||||
{formatDateHeader(dateKey)} <span className="ml-1 text-ink-300">· {group.length} 场</span>
|
{formatDateHeader(dateKey)}
|
||||||
|
<span className="ml-2 text-2xs font-normal text-ink-400">{group.length} 场</span>
|
||||||
</div>
|
</div>
|
||||||
{group.map(m => {
|
{group.map(m => {
|
||||||
const st = STATUS_META[m.match_status] ?? { label: m.match_status, cls: 'text-ink-400' }
|
const st = STATUS_META[m.match_status] ?? { label: m.match_status, cls: 'text-ink-400' }
|
||||||
@@ -503,7 +556,7 @@ export default function Matches() {
|
|||||||
<div key={m.id}>
|
<div key={m.id}>
|
||||||
{/* 行:可点击展开 */}
|
{/* 行:可点击展开 */}
|
||||||
<div
|
<div
|
||||||
className={`border-b border-ink-200 px-1 py-3 transition-colors hover:bg-paper-100 cursor-pointer ${
|
className={`border-b border-ink-200 px-4 py-5 transition-colors hover:bg-paper-100/70 cursor-pointer sm:px-1 sm:py-4 ${
|
||||||
expanded ? 'bg-paper-100/60' : ''
|
expanded ? 'bg-paper-100/60' : ''
|
||||||
}`}
|
}`}
|
||||||
onClick={toggleExpand}
|
onClick={toggleExpand}
|
||||||
@@ -512,74 +565,79 @@ export default function Matches() {
|
|||||||
onKeyDown={e => { if (e.key === 'Enter') toggleExpand() }}
|
onKeyDown={e => { if (e.key === 'Enter') toggleExpand() }}
|
||||||
aria-expanded={expanded}
|
aria-expanded={expanded}
|
||||||
>
|
>
|
||||||
{/* 小屏:日期+状态行;桌面:日期单独一列 */}
|
{/* 桌面 grid: 日期 | 主队 | 比分 | 客队 | 状态 | 按钮 */}
|
||||||
<div className="flex items-center justify-between sm:contents">
|
<div className="flex flex-col gap-3 sm:grid sm:grid-cols-[96px_minmax(0,1fr)_72px_minmax(0,1fr)_64px_88px] sm:items-center sm:gap-x-4 sm:gap-y-0">
|
||||||
<span className="text-2xs tabular-nums text-ink-400">{fmtDate(m.match_date)}</span>
|
{/* 日期 + 状态:小屏同行;桌面 date 单独一列 */}
|
||||||
<span className={`text-2xs sm:hidden ${st.cls}`}>{st.label}</span>
|
<div className="flex items-center justify-between text-xs sm:contents">
|
||||||
</div>
|
<span className="tabular-nums text-ink-500 sm:text-xs">{fmtTime(m.match_date)}</span>
|
||||||
|
<span className={`sm:hidden ${st.cls}`}>{st.label}</span>
|
||||||
{/* 对阵行:小屏主队(弹性)/比分/客队(弹性)三格;桌面 sm:contents 走 grid */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="flex min-w-0 flex-1 items-center justify-end gap-1.5">
|
|
||||||
<TeamSideTag side="home" />
|
|
||||||
<span className="truncate text-sm font-medium text-ink-900">{homeName}</span>
|
|
||||||
</span>
|
|
||||||
<span className="flex w-16 flex-shrink-0 flex-col items-center">
|
|
||||||
{m.home_goals !== null && m.away_goals !== null ? (
|
|
||||||
<span className="font-serif text-base font-bold tabular-nums text-ink-900">
|
|
||||||
{m.home_goals}<span className="mx-0.5 font-normal text-ink-300">:</span>{m.away_goals}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-2xs tracking-widest text-ink-400">VS</span>
|
|
||||||
)}
|
|
||||||
{m.home_xg !== null && m.away_xg != null && (
|
|
||||||
<span className="text-2xs tabular-nums text-ink-400">
|
|
||||||
xG {m.home_xg.toFixed(1)}-{m.away_xg.toFixed(1)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span className="flex min-w-0 flex-1 items-center gap-1.5">
|
|
||||||
<TeamSideTag side="away" />
|
|
||||||
<span className="truncate text-sm font-medium text-ink-900">{awayName}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 预测按钮:小屏独占一行(桌面端 sm:contents 下隐藏) */}
|
|
||||||
{!finished && (
|
|
||||||
<div className="flex justify-end sm:hidden" onClick={e => e.stopPropagation()}>
|
|
||||||
<button
|
|
||||||
onClick={() => predict(m)}
|
|
||||||
disabled={busy}
|
|
||||||
className="btn min-h-[44px] px-4"
|
|
||||||
title={`以${mode === 'multi' ? '多专家' : '单次'}模式预测这场`}
|
|
||||||
>
|
|
||||||
{busy ? (<><Spinner /> 预测中</>) : '预测'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 桌面端按钮(小屏隐藏) */}
|
{/* 主队 + 比分 + 客队:移动端 grid 三列(严格居中),桌面端 grid 分列 */}
|
||||||
<span className={`hidden text-right text-2xs sm:block ${st.cls}`}>{st.label}</span>
|
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-4 sm:contents">
|
||||||
<div className="hidden sm:flex sm:justify-end" onClick={e => e.stopPropagation()}>
|
{/* 主队(右对齐) */}
|
||||||
|
<span className="flex min-w-0 items-center justify-end gap-2">
|
||||||
|
<TeamSideTag side="home" />
|
||||||
|
<span className="truncate text-sm font-medium text-ink-900">{homeName}</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* 比分 / VS(严格居中) */}
|
||||||
|
<span className="flex flex-col items-center justify-center">
|
||||||
|
{m.home_goals !== null && m.away_goals !== null ? (
|
||||||
|
<span className="font-serif text-xl font-bold tabular-nums leading-none text-ink-900 sm:text-xl">
|
||||||
|
{m.home_goals}<span className="mx-1 font-normal text-ink-300">:</span>{m.away_goals}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm tracking-[0.2em] text-ink-500">VS</span>
|
||||||
|
)}
|
||||||
|
{m.home_xg !== null && m.away_xg !== null && (
|
||||||
|
<span className="mt-0.5 text-2xs tabular-nums text-ink-400">
|
||||||
|
xG {m.home_xg.toFixed(1)}–{m.away_xg.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* 客队(左对齐) */}
|
||||||
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
|
<TeamSideTag side="away" />
|
||||||
|
<span className="truncate text-sm font-medium text-ink-900">{awayName}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 状态标签:小屏隐藏(已有);桌面用徽标样式 */}
|
||||||
|
<span className="hidden text-right sm:block">
|
||||||
|
<span className={`inline-block border px-1.5 py-0.5 text-2xs leading-tight ${st.cls} ${
|
||||||
|
m.match_status === 'finished'
|
||||||
|
? 'border-ink-200 text-ink-500'
|
||||||
|
: m.match_status === 'scheduled'
|
||||||
|
? 'border-ink-300 text-ink-600'
|
||||||
|
: 'border-press/30 text-press'
|
||||||
|
}`}>
|
||||||
|
{st.label}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* 预测按钮(统一,响应式尺寸) */}
|
||||||
{!finished && (
|
{!finished && (
|
||||||
<button
|
<div className="flex justify-end" onClick={e => e.stopPropagation()}>
|
||||||
onClick={() => predict(m)}
|
<button
|
||||||
disabled={busy}
|
onClick={() => predict(m)}
|
||||||
className="btn btn-sm w-[76px]"
|
disabled={busy}
|
||||||
title={`以${mode === 'multi' ? '多专家' : '单次'}模式预测这场`}
|
className={`btn ${busy ? '' : 'btn-solid'} w-full min-h-[44px] sm:w-[84px] sm:min-h-0 sm:btn-sm`}
|
||||||
>
|
title="以多专家模式预测这场"
|
||||||
{busy ? (<><Spinner /> 预测中</>) : '预测'}
|
>
|
||||||
</button>
|
{busy ? (<><Spinner /> 预测中</>) : '预测'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>{/* 关闭可点击行(clickable row) */}
|
</div>{/* 关闭可点击行 */}
|
||||||
|
|
||||||
{/* 展开详情面板(只读数据 + 预测按钮 + 历史预测 + 专家报告入口) */}
|
{/* 展开详情面板 */}
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<MatchDetailPanel
|
<MatchDetailPanel
|
||||||
match={m} detail={detail} ctx={ctx}
|
match={m} detail={detail} ctx={ctx}
|
||||||
loading={detailLoading === m.id}
|
loading={detailLoading === m.id}
|
||||||
mode={mode}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -588,15 +646,49 @@ export default function Matches() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{!loading && nextCursor && (
|
{/* 单一「加载更多」按钮:3 天视图时先展开,展开后从服务器拉取下一页 */}
|
||||||
|
{!loading && (hasHiddenUpcoming || nextCursor) && (
|
||||||
<div className="flex justify-center pt-4">
|
<div className="flex justify-center pt-4">
|
||||||
<button onClick={loadMore} disabled={loadingMore} className="btn btn-sm">
|
{hasHiddenUpcoming ? (
|
||||||
{loadingMore ? (<><Spinner /> 获取中</>) : '载入更多'}
|
<button
|
||||||
|
onClick={() => setShowAllUpcoming(true)}
|
||||||
|
className="btn btn-outline min-h-[44px] w-full max-w-xs sm:w-auto"
|
||||||
|
>
|
||||||
|
显示后续 {matches.length - visibleMatches.length} 场未开赛
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button onClick={loadMore} disabled={loadingMore} className="btn min-h-[44px] w-full max-w-xs sm:w-auto">
|
||||||
|
{loadingMore ? (<><Spinner /> 获取中</>) : '载入更多赛程'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 已展开全部但未开赛:提供「收起」回未来 3 天 */}
|
||||||
|
{!loading && isScheduledView && showAllUpcoming && matches.length > 0 && (
|
||||||
|
<div className="flex justify-center pt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAllUpcoming(false)}
|
||||||
|
className="text-xs text-ink-400 hover:text-ink-700 transition-colors"
|
||||||
|
>
|
||||||
|
收起,仅显示未来 3 天
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* 回到顶部按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={scrollToTop}
|
||||||
|
className={`fixed bottom-6 right-6 z-40 flex h-10 w-10 items-center justify-center rounded-full border border-ink-200 bg-paper-50 text-ink-600 shadow-lg transition-all duration-300 hover:border-ink-400 hover:text-ink-900 ${
|
||||||
|
showBackTop ? 'translate-y-0 opacity-100' : 'translate-y-4 opacity-0 pointer-events-none'
|
||||||
|
}`}
|
||||||
|
aria-label="回到顶部"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -607,10 +699,10 @@ function formatDateHeader(dateKey: string): string {
|
|||||||
const d = new Date(dateKey + 'T00:00:00')
|
const d = new Date(dateKey + 'T00:00:00')
|
||||||
if (isNaN(d.getTime())) return dateKey
|
if (isNaN(d.getTime())) return dateKey
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
const todayKey = today.toISOString().slice(0, 10)
|
const todayKey = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
|
||||||
const tmr = new Date(today)
|
const tmr = new Date(today)
|
||||||
tmr.setDate(tmr.getDate() + 1)
|
tmr.setDate(tmr.getDate() + 1)
|
||||||
const tmrKey = tmr.toISOString().slice(0, 10)
|
const tmrKey = `${tmr.getFullYear()}-${String(tmr.getMonth() + 1).padStart(2, '0')}-${String(tmr.getDate()).padStart(2, '0')}`
|
||||||
const weekday = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][d.getDay()]
|
const weekday = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][d.getDay()]
|
||||||
if (dateKey === todayKey) return `今日 ${weekday}`
|
if (dateKey === todayKey) return `今日 ${weekday}`
|
||||||
if (dateKey === tmrKey) return `明日 ${weekday}`
|
if (dateKey === tmrKey) return `明日 ${weekday}`
|
||||||
@@ -652,7 +744,7 @@ function PredictionCost({ prediction }: { prediction: Prediction }) {
|
|||||||
|
|
||||||
|
|
||||||
/** 预测过程阶段(按时长模拟;结果到达即跳到完成) */
|
/** 预测过程阶段(按时长模拟;结果到达即跳到完成) */
|
||||||
function PredictProgress({ mode }: { mode: 'single' | 'multi' }) {
|
function PredictProgress() {
|
||||||
const [elapsed, setElapsed] = useState(0)
|
const [elapsed, setElapsed] = useState(0)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t = setInterval(() => setElapsed(e => e + 0.5), 500)
|
const t = setInterval(() => setElapsed(e => e + 0.5), 500)
|
||||||
@@ -660,18 +752,16 @@ function PredictProgress({ mode }: { mode: 'single' | 'multi' }) {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// 阶段阈值(秒): 切片 → 专家(各路依次点亮) → 终裁
|
// 阶段阈值(秒): 切片 → 专家(各路依次点亮) → 终裁
|
||||||
const SLICE_END = mode === 'multi' ? 3 : 3
|
const SLICE_END = 3
|
||||||
const AGENT_START = 4
|
const AGENT_START = 4
|
||||||
const AGENT_STEP = 8 // 每路专家约 8s 点亮一路
|
const AGENT_STEP = 8 // 每路专家约 8s 点亮一路
|
||||||
const AGG_START = mode === 'multi' ? AGENT_START + AGENT_STEP * 5 : SLICE_END + 1
|
const AGG_START = AGENT_START + AGENT_STEP * 5
|
||||||
const agents = ['form', 'stats', 'home_away', 'injuries', 'h2h']
|
const agents = ['form', 'stats', 'home_away', 'standings', 'h2h']
|
||||||
|
|
||||||
const phase = elapsed < SLICE_END ? 'slice'
|
const phase = elapsed < SLICE_END ? 'slice'
|
||||||
: mode === 'single'
|
: elapsed < AGG_START ? 'agents' : 'agg'
|
||||||
? 'model'
|
|
||||||
: elapsed < AGG_START ? 'agents' : 'agg'
|
|
||||||
|
|
||||||
const pct = Math.min(95, Math.round((elapsed / (mode === 'multi' ? 70 : 20)) * 100))
|
const pct = Math.min(95, Math.round((elapsed / 70) * 100))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-5 py-8 sm:px-8">
|
<div className="px-5 py-8 sm:px-8">
|
||||||
@@ -681,7 +771,6 @@ function PredictProgress({ mode }: { mode: 'single' | 'multi' }) {
|
|||||||
<span className="font-serif text-sm font-bold text-ink-900">
|
<span className="font-serif text-sm font-bold text-ink-900">
|
||||||
{phase === 'slice' && '正在组装比赛数据切片'}
|
{phase === 'slice' && '正在组装比赛数据切片'}
|
||||||
{phase === 'agents' && '五路专家并行分析中'}
|
{phase === 'agents' && '五路专家并行分析中'}
|
||||||
{phase === 'model' && '模型分析中'}
|
|
||||||
{phase === 'agg' && '终裁专家汇总裁定中'}
|
{phase === 'agg' && '终裁专家汇总裁定中'}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-2xs tabular-nums text-ink-400">{elapsed.toFixed(0)}s</span>
|
<span className="text-2xs tabular-nums text-ink-400">{elapsed.toFixed(0)}s</span>
|
||||||
@@ -690,14 +779,13 @@ function PredictProgress({ mode }: { mode: 'single' | 'multi' }) {
|
|||||||
{/* 进度条:渐进式,不封顶到 100% */}
|
{/* 进度条:渐进式,不封顶到 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="mx-auto mt-5 h-1 w-full max-w-md overflow-hidden bg-ink-100" role="progressbar" aria-valuenow={pct}>
|
||||||
<div
|
<div
|
||||||
className={`h-full bg-press transition-all duration-500 ${phase === 'agg' || phase === 'model' ? 'animate-pulse' : ''}`}
|
className={`h-full bg-press transition-all duration-500 ${phase === 'agg' ? 'animate-pulse' : ''}`}
|
||||||
style={{ width: `${pct}%` }}
|
style={{ width: `${pct}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 专家灯序(多专家模式) */}
|
{/* 专家灯序(多专家模式) */}
|
||||||
{mode === 'multi' && (
|
<ul className="mx-auto mt-6 max-w-md space-y-1.5">
|
||||||
<ul className="mx-auto mt-6 max-w-md space-y-1.5">
|
|
||||||
{agents.map((a, i) => {
|
{agents.map((a, i) => {
|
||||||
const lit = elapsed >= AGENT_START + AGENT_STEP * (i + 1)
|
const lit = elapsed >= AGENT_START + AGENT_STEP * (i + 1)
|
||||||
const activeNow = !lit && elapsed >= AGENT_START + AGENT_STEP * i
|
const activeNow = !lit && elapsed >= AGENT_START + AGENT_STEP * i
|
||||||
@@ -721,18 +809,13 @@ function PredictProgress({ mode }: { mode: 'single' | 'multi' }) {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
|
||||||
|
|
||||||
<p className="mt-6 text-center text-2xs text-ink-400">
|
<p className="mt-6 text-center text-2xs text-ink-400">
|
||||||
{mode === 'multi'
|
五路专家并行分析后终裁,约需 30-90 秒;多专家调用消耗较多 token,请按需使用。关闭窗口即取消
|
||||||
? '五路专家并行分析后终裁,约需 30-90 秒;多专家调用消耗较多 token,请按需使用。关闭窗口即取消'
|
</p>
|
||||||
: '单次调用,约需 5-20 秒;关闭窗口即取消'}
|
<p className="mt-1 text-center text-2xs text-ink-300">
|
||||||
|
提示:每分钟限 10 次预测,耗尽后需等待下一分钟。
|
||||||
</p>
|
</p>
|
||||||
{mode === 'multi' && (
|
|
||||||
<p className="mt-1 text-center text-2xs text-ink-300">
|
|
||||||
提示:每分钟限 10 次预测,耗尽后需等待下一分钟。
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -740,14 +823,12 @@ function PredictProgress({ mode }: { mode: 'single' | 'multi' }) {
|
|||||||
/** 预测弹窗:进行中显示过程可视化,完成后显示预测版,失败显示原因 */
|
/** 预测弹窗:进行中显示过程可视化,完成后显示预测版,失败显示原因 */
|
||||||
function PredictModal({
|
function PredictModal({
|
||||||
match,
|
match,
|
||||||
mode,
|
|
||||||
predicting,
|
predicting,
|
||||||
prediction,
|
prediction,
|
||||||
error,
|
error,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
match: Match
|
match: Match
|
||||||
mode: 'single' | 'multi'
|
|
||||||
predicting: boolean
|
predicting: boolean
|
||||||
prediction: Prediction | null
|
prediction: Prediction | null
|
||||||
error: string | null
|
error: string | null
|
||||||
@@ -774,9 +855,9 @@ function PredictModal({
|
|||||||
if (e.target === e.currentTarget) onClose()
|
if (e.target === e.currentTarget) onClose()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="relative w-full max-w-2xl bg-paper-50 shadow-2xl">
|
<div className="relative flex max-h-[92vh] w-full max-w-2xl flex-col overflow-hidden 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">
|
<div className="flex flex-shrink-0 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">
|
<h3 className="flex flex-wrap items-center gap-1.5 font-serif text-sm font-bold text-ink-900">
|
||||||
预测版 ·
|
预测版 ·
|
||||||
<TeamSideTag side="home" />
|
<TeamSideTag side="home" />
|
||||||
@@ -787,7 +868,7 @@ function PredictModal({
|
|||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="flex h-7 w-7 items-center justify-center text-ink-400 transition-colors hover:text-ink-900"
|
className="flex h-11 w-11 flex-shrink-0 items-center justify-center text-ink-400 transition-colors hover:text-ink-900"
|
||||||
aria-label="关闭"
|
aria-label="关闭"
|
||||||
>
|
>
|
||||||
<svg className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<svg className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
@@ -796,9 +877,10 @@ function PredictModal({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 弹窗体 */}
|
{/* 弹窗体(小屏可滚动) */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
{predicting ? (
|
{predicting ? (
|
||||||
<PredictProgress mode={mode} />
|
<PredictProgress />
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="px-5 py-10 text-center sm:px-8">
|
<div className="px-5 py-10 text-center sm:px-8">
|
||||||
<p className="font-serif text-sm font-bold text-press">预测失败</p>
|
<p className="font-serif text-sm font-bold text-press">预测失败</p>
|
||||||
@@ -808,8 +890,9 @@ function PredictModal({
|
|||||||
<button onClick={onClose} className="btn btn-sm mt-6">关闭</button>
|
<button onClick={onClose} className="btn btn-sm mt-6">关闭</button>
|
||||||
</div>
|
</div>
|
||||||
) : prediction ? (
|
) : prediction ? (
|
||||||
<PredictionPanel prediction={prediction} match={match} mode={mode} embedded />
|
<PredictionPanel prediction={prediction} match={match} embedded />
|
||||||
) : null}
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -818,12 +901,10 @@ function PredictModal({
|
|||||||
function PredictionPanel({
|
function PredictionPanel({
|
||||||
prediction,
|
prediction,
|
||||||
match,
|
match,
|
||||||
mode,
|
|
||||||
embedded = false,
|
embedded = false,
|
||||||
}: {
|
}: {
|
||||||
prediction: Prediction
|
prediction: Prediction
|
||||||
match: Match
|
match: Match
|
||||||
mode: 'single' | 'multi'
|
|
||||||
/** 弹窗嵌入模式:弹窗已提供报头,这里省略自带版头 */
|
/** 弹窗嵌入模式:弹窗已提供报头,这里省略自带版头 */
|
||||||
embedded?: boolean
|
embedded?: boolean
|
||||||
}) {
|
}) {
|
||||||
@@ -899,7 +980,7 @@ function PredictionPanel({
|
|||||||
|
|
||||||
{/* ── 元信息 ── */}
|
{/* ── 元信息 ── */}
|
||||||
<p className="text-center text-2xs text-ink-500">
|
<p className="text-center text-2xs text-ink-500">
|
||||||
{mode === 'multi' ? `多专家模式 · ${okReports.length}/${reports.length} 路有效` : '单次模式'}
|
`多专家模式 · ${okReports.length}/${reports.length} 路有效`
|
||||||
{prediction.prompt_version && ` · prompt ${prediction.prompt_version}`}
|
{prediction.prompt_version && ` · prompt ${prediction.prompt_version}`}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -914,7 +995,7 @@ function PredictionPanel({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── 专家意见(多模式):可折叠 + 状态摘要 + 权重条形图 ── */}
|
{/* ── 专家意见(多模式):可折叠 + 状态摘要 + 权重条形图 ── */}
|
||||||
{mode === 'multi' && reports.length > 0 && (
|
{reports.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<button
|
<button
|
||||||
onClick={() => setExpertsOpen(o => !o)}
|
onClick={() => setExpertsOpen(o => !o)}
|
||||||
@@ -953,7 +1034,7 @@ function PredictionPanel({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── 终裁意见(success) ── */}
|
{/* ── 终裁意见(success) ── */}
|
||||||
{prediction.reasoning && !degraded && mode === 'multi' && (
|
{prediction.reasoning && !degraded && (
|
||||||
<section>
|
<section>
|
||||||
<h4 className="section-head mb-3">终裁意见</h4>
|
<h4 className="section-head mb-3">终裁意见</h4>
|
||||||
<blockquote className="border-l-2 border-press pl-4">
|
<blockquote className="border-l-2 border-press pl-4">
|
||||||
@@ -1067,13 +1148,12 @@ function AgentCard({ report: r, no }: { report: AgentReport; no: string }) {
|
|||||||
|
|
||||||
/** 比赛详情面板:双方近况/H2H + 历史预测列表(只读) */
|
/** 比赛详情面板:双方近况/H2H + 历史预测列表(只读) */
|
||||||
function MatchDetailPanel({
|
function MatchDetailPanel({
|
||||||
match, detail, ctx, loading, mode,
|
match, detail, ctx, loading,
|
||||||
}: {
|
}: {
|
||||||
match: Match
|
match: Match
|
||||||
detail: MatchDetailOut | undefined
|
detail: MatchDetailOut | undefined
|
||||||
ctx: MatchContextOut | undefined
|
ctx: MatchContextOut | undefined
|
||||||
loading: boolean
|
loading: boolean
|
||||||
mode: 'single' | 'multi'
|
|
||||||
}) {
|
}) {
|
||||||
const homeName = match.home_team_zh || match.home_team
|
const homeName = match.home_team_zh || match.home_team
|
||||||
const awayName = match.away_team_zh || match.away_team
|
const awayName = match.away_team_zh || match.away_team
|
||||||
@@ -1106,11 +1186,16 @@ function MatchDetailPanel({
|
|||||||
</div>
|
</div>
|
||||||
{!finished && (
|
{!finished && (
|
||||||
<span className="text-2xs text-ink-500">
|
<span className="text-2xs text-ink-500">
|
||||||
点击行首「预测」按钮发起{mode === 'multi' ? '多专家' : '单次'}分析
|
点击行首「预测」按钮发起多专家分析
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 比赛详细统计(bzzoiro /events/{id}/stats/) */}
|
||||||
|
{detail?.stats && (
|
||||||
|
<MatchStatsPanel stats={detail.stats} homeName={homeName} awayName={awayName} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 双方近况 + H2H */}
|
{/* 双方近况 + H2H */}
|
||||||
{(ctx?.home_recent?.length || ctx?.away_recent?.length || ctx?.h2h?.length) ? (
|
{(ctx?.home_recent?.length || ctx?.away_recent?.length || ctx?.h2h?.length) ? (
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
@@ -1128,7 +1213,7 @@ function MatchDetailPanel({
|
|||||||
{detail?.recent_predictions?.length ? (
|
{detail?.recent_predictions?.length ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{detail.recent_predictions.map(p => (
|
{detail.recent_predictions.map(p => (
|
||||||
<PredictionHistoryRow key={p.id} p={p} mode={mode} />
|
<PredictionHistoryRow key={p.id} p={p} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -1141,6 +1226,80 @@ function MatchDetailPanel({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 比赛详细统计面板(bzzoiro /events/{id}/stats/) */
|
||||||
|
function MatchStatsPanel({
|
||||||
|
stats, homeName, awayName,
|
||||||
|
}: { stats: MatchStatsDetail; homeName: string; awayName: string }) {
|
||||||
|
const rows: Array<{ label: string; home: number | null; away: number | null; highlight?: 'high' | 'low' }> = [
|
||||||
|
{ label: '预期进球(xG)', home: stats.home_xg, away: stats.away_xg },
|
||||||
|
{ label: '射门', home: stats.home_shots, away: stats.away_shots },
|
||||||
|
{ label: '射正', home: stats.home_shots_on_target, away: stats.away_shots_on_target },
|
||||||
|
{ label: '角球', home: stats.home_corners, away: stats.away_corners },
|
||||||
|
{ label: '犯规', home: stats.home_fouls, away: stats.away_fouls },
|
||||||
|
{ label: '绝佳机会', home: stats.home_big_chances, away: stats.away_big_chances },
|
||||||
|
{ label: '黄牌', home: stats.home_yellow_cards, away: stats.away_yellow_cards },
|
||||||
|
{ label: '红牌', home: stats.home_red_cards, away: stats.away_red_cards },
|
||||||
|
]
|
||||||
|
const hasAny = rows.some(r => r.home != null || r.away != null)
|
||||||
|
if (!hasAny) return null
|
||||||
|
|
||||||
|
// 控球率用横条展示
|
||||||
|
const possHome = stats.home_possession
|
||||||
|
const possAway = possHome != null ? Math.max(0, 100 - possHome) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h4 className="section-head mb-2">比赛统计</h4>
|
||||||
|
|
||||||
|
{/* 控球率横条 */}
|
||||||
|
{possHome != null && possAway != null && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="mb-1 flex justify-between text-2xs text-ink-500">
|
||||||
|
<span>{possHome.toFixed(0)}%</span>
|
||||||
|
<span className="text-ink-400">控球率</span>
|
||||||
|
<span>{possAway.toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex h-1.5 overflow-hidden rounded-full bg-ink-200">
|
||||||
|
<div className="bg-ink-700 transition-[width] duration-500" style={{ width: `${possHome}%` }} />
|
||||||
|
<div className="bg-ink-300 transition-[width] duration-500" style={{ width: `${possAway}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 主客对比表 */}
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-ink-200 text-ink-400">
|
||||||
|
<th className="py-1.5 text-left font-medium">{homeName}</th>
|
||||||
|
<th className="py-1.5 text-center font-medium text-ink-500">统计项</th>
|
||||||
|
<th className="py-1.5 text-right font-medium">{awayName}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.filter(r => r.home != null || r.away != null).map(r => {
|
||||||
|
const h = r.home ?? 0
|
||||||
|
const a = r.away ?? 0
|
||||||
|
const winner = h > a ? 'home' : h < a ? 'away' : 'tie'
|
||||||
|
return (
|
||||||
|
<tr key={r.label} className="border-b border-ink-100">
|
||||||
|
<td className={`py-1.5 text-right tabular-nums ${winner === 'home' ? 'font-bold text-ink-900' : 'text-ink-500'}`}>
|
||||||
|
{r.home ?? '—'}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 text-center text-ink-500">{r.label}</td>
|
||||||
|
<td className={`py-1.5 text-left tabular-nums ${winner === 'away' ? 'font-bold text-ink-900' : 'text-ink-500'}`}>
|
||||||
|
{r.away ?? '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** 近况/H2H 单区块 */
|
/** 近况/H2H 单区块 */
|
||||||
function RecentBlock({ title, rows, side }: { title: string; rows?: TeamRecentMatch[]; side: 'home' | 'away' | 'h2h' }) {
|
function RecentBlock({ title, rows, side }: { title: string; rows?: TeamRecentMatch[]; side: 'home' | 'away' | 'h2h' }) {
|
||||||
return (
|
return (
|
||||||
@@ -1149,7 +1308,7 @@ function RecentBlock({ title, rows, side }: { title: string; rows?: TeamRecentMa
|
|||||||
{rows && rows.length > 0 ? (
|
{rows && rows.length > 0 ? (
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-1">
|
||||||
{rows.map((r, i) => {
|
{rows.map((r, i) => {
|
||||||
const date = r.match_date ? r.match_date.slice(5, 10) : '—'
|
const date = r.match_date ? new Date(r.match_date).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' }) : '—'
|
||||||
const score = (r.home_goals != null && r.away_goals != null) ? `${r.home_goals}-${r.away_goals}` : 'vs'
|
const score = (r.home_goals != null && r.away_goals != null) ? `${r.home_goals}-${r.away_goals}` : 'vs'
|
||||||
const label = side === 'h2h'
|
const label = side === 'h2h'
|
||||||
? `${r.home_team ?? '?'} ${score} ${r.away_team ?? '?'}`
|
? `${r.home_team ?? '?'} ${score} ${r.away_team ?? '?'}`
|
||||||
@@ -1170,7 +1329,7 @@ function RecentBlock({ title, rows, side }: { title: string; rows?: TeamRecentMa
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 历史预测单行(含专家报告入口) */
|
/** 历史预测单行(含专家报告入口) */
|
||||||
function PredictionHistoryRow({ p, mode }: { p: MatchRecentPrediction; mode: 'single' | 'multi' }) {
|
function PredictionHistoryRow({ p }: { p: MatchRecentPrediction }) {
|
||||||
const badge = p.status === 'degraded'
|
const badge = p.status === 'degraded'
|
||||||
? { label: 'degraded', cls: 'text-press' }
|
? { label: 'degraded', cls: 'text-press' }
|
||||||
: p.settled
|
: p.settled
|
||||||
@@ -1181,7 +1340,7 @@ function PredictionHistoryRow({ p, mode }: { p: MatchRecentPrediction; mode: 'si
|
|||||||
: '—'
|
: '—'
|
||||||
const alt = (p.alt_pred_home_goals != null && p.alt_pred_away_goals != null)
|
const alt = (p.alt_pred_home_goals != null && p.alt_pred_away_goals != null)
|
||||||
? `${p.alt_pred_home_goals.toFixed(1)}-${p.alt_pred_away_goals.toFixed(1)}` : null
|
? `${p.alt_pred_home_goals.toFixed(1)}-${p.alt_pred_away_goals.toFixed(1)}` : null
|
||||||
const hasAgents = mode === 'multi' && p.agent_outputs && p.agent_outputs.length > 0
|
const hasAgents = p.agent_outputs && p.agent_outputs.length > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-b border-ink-200 pb-2 last:border-b-0">
|
<div className="border-b border-ink-200 pb-2 last:border-b-0">
|
||||||
@@ -1198,7 +1357,7 @@ function PredictionHistoryRow({ p, mode }: { p: MatchRecentPrediction; mode: 'si
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 flex items-center justify-between text-2xs text-ink-400">
|
<div className="mt-0.5 flex items-center justify-between text-2xs text-ink-400">
|
||||||
<span className="truncate">{p.model} · {p.mode} · {p.created_at?.slice(0, 16).replace('T', ' ') ?? '—'}</span>
|
<span className="truncate">{p.model} · {p.mode} · {p.created_at ? new Date(p.created_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }) : '—'}</span>
|
||||||
{hasAgents && <span className="text-press">{p.agent_outputs!.length} 路专家报告</span>}
|
{hasAgents && <span className="text-press">{p.agent_outputs!.length} 路专家报告</span>}
|
||||||
</div>
|
</div>
|
||||||
{p.reasoning && (
|
{p.reasoning && (
|
||||||
@@ -1220,7 +1379,7 @@ function readablePredictError(e: unknown): string {
|
|||||||
if (/402|Payment Required|额度|余额/.test(m)) return 'LLM 额度不足(402),请检查 API Key 余额'
|
if (/402|Payment Required|额度|余额/.test(m)) return 'LLM 额度不足(402),请检查 API Key 余额'
|
||||||
if (/400|已完赛/.test(m)) return '该比赛已完赛,不再支持预测'
|
if (/400|已完赛/.test(m)) return '该比赛已完赛,不再支持预测'
|
||||||
if (/409|已结算/.test(m)) return '该预测已结算,不能重新预测'
|
if (/409|已结算/.test(m)) return '该预测已结算,不能重新预测'
|
||||||
if (/timeout|超时|timed out/i.test(m)) return '请求超时,请稍后重试或改用单次模式'
|
if (/timeout|超时|timed out/i.test(m)) return '请求超时,请稍后重试'
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
return String(e)
|
return String(e)
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
/**
|
||||||
|
* 主站 - 联赛积分榜页
|
||||||
|
*
|
||||||
|
* 展示各联赛最新积分榜(位置/积分/净胜/xG差/近期走势/分区),
|
||||||
|
* 数据来自 bzzoiro /leagues/{id}/standings/ 管线采集。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { fetchStandings } from '../admin/dal'
|
||||||
|
import type { StandingsLeague, StandingRow } from '../admin/dal'
|
||||||
|
import { Spinner } from '../admin/components'
|
||||||
|
|
||||||
|
const LEAGUES = [
|
||||||
|
{ code: 'E0', name: '英超' },
|
||||||
|
{ code: 'SP1', name: '西甲' },
|
||||||
|
{ code: 'D1', name: '德甲' },
|
||||||
|
{ code: 'I1', name: '意甲' },
|
||||||
|
{ code: 'F1', name: '法甲' },
|
||||||
|
{ code: 'CL', name: '欧冠' },
|
||||||
|
{ code: 'EL', name: '欧联' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const ZONE_META: Record<string, { label: string; cls: string }> = {
|
||||||
|
// 欧战资格
|
||||||
|
'Champions League': { label: '欧冠区', cls: 'bg-emerald-100 text-emerald-700' },
|
||||||
|
'Champions League Qualification': { label: '欧冠资格', cls: 'bg-emerald-100 text-emerald-700' },
|
||||||
|
'Europa League': { label: '欧联区', cls: 'bg-amber-100 text-amber-700' },
|
||||||
|
'Conference League': { label: '欧协杯', cls: 'bg-sky-100 text-sky-700' },
|
||||||
|
'Conference League Qualification': { label: '欧协杯', cls: 'bg-sky-100 text-sky-700' },
|
||||||
|
'Europa Conference League': { label: '欧协杯', cls: 'bg-sky-100 text-sky-700' },
|
||||||
|
'Europa Conference League Qualification': { label: '欧协杯', cls: 'bg-sky-100 text-sky-700' },
|
||||||
|
// 升级
|
||||||
|
'Championship': { label: '升级区', cls: 'bg-emerald-100 text-emerald-700' },
|
||||||
|
'Promotion': { label: '升级区', cls: 'bg-emerald-100 text-emerald-700' },
|
||||||
|
'Promotion Group': { label: '升级组', cls: 'bg-emerald-100 text-emerald-700' },
|
||||||
|
// 降级
|
||||||
|
'Relegation': { label: '降级区', cls: 'bg-rose-100 text-rose-700' },
|
||||||
|
'Relegation Playoffs': { label: '降级附加赛', cls: 'bg-orange-100 text-orange-700' },
|
||||||
|
'Relegation Group': { label: '降级组', cls: 'bg-rose-100 text-rose-700' },
|
||||||
|
// 附加赛
|
||||||
|
'Playoffs': { label: '附加赛', cls: 'bg-amber-100 text-amber-700' },
|
||||||
|
'Championship Playoffs': { label: '升级附加赛', cls: 'bg-amber-100 text-amber-700' },
|
||||||
|
'Qualification Playoffs': { label: '资格附加赛', cls: 'bg-sky-100 text-sky-700' },
|
||||||
|
'Qualification': { label: '资格赛', cls: 'bg-sky-100 text-sky-700' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoneBadge(zone?: string | null) {
|
||||||
|
if (!zone) return null
|
||||||
|
const meta = ZONE_META[zone] ?? { label: zone, cls: 'bg-ink-100 text-ink-600' }
|
||||||
|
return <span className={`whitespace-nowrap rounded px-1.5 py-0.5 text-2xs font-medium ${meta.cls}`}>{meta.label}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 近期走势串(W/D/L) → 彩色圆点 */
|
||||||
|
function FormDots({ form }: { form?: string | null }) {
|
||||||
|
if (!form) return <span className="text-2xs text-ink-400">—</span>
|
||||||
|
const colorMap: Record<string, string> = { W: 'bg-emerald-500', D: 'bg-ink-300', L: 'bg-rose-500' }
|
||||||
|
return (
|
||||||
|
<span className="inline-flex gap-0.5">
|
||||||
|
{form.slice(0, 5).split('').map((c, i) => (
|
||||||
|
<span key={i} className={`inline-block h-1.5 w-1.5 rounded-full ${colorMap[c] ?? 'bg-ink-200'}`} />
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StandingsPage() {
|
||||||
|
const [leagues, setLeagues] = useState<StandingsLeague[]>([])
|
||||||
|
const [activeLeague, setActiveLeague] = useState<string>('')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [switching, setSwitching] = useState(false) // 切换联赛中
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [showBackTop, setShowBackTop] = useState(false) // 回到顶部按钮显示态
|
||||||
|
|
||||||
|
// 监听滚动,超过 300px 显示回到顶部按钮
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScroll = () => setShowBackTop(window.scrollY > 300)
|
||||||
|
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||||
|
return () => window.removeEventListener('scroll', handleScroll)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const scrollToTop = () => window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
|
|
||||||
|
const load = useCallback(async (code?: string) => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const data = await fetchStandings(code)
|
||||||
|
setLeagues(data.leagues)
|
||||||
|
if (!activeLeague && data.leagues.length > 0) {
|
||||||
|
setActiveLeague(data.leagues[0].league_code)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '加载失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [activeLeague])
|
||||||
|
|
||||||
|
useEffect(() => { load() }, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// 切换联赛(带加载态,禁用 tab 防重复点击)
|
||||||
|
const switchLeague = async (code: string) => {
|
||||||
|
if (code === activeLeague || switching) return
|
||||||
|
setSwitching(true)
|
||||||
|
setActiveLeague(code)
|
||||||
|
try {
|
||||||
|
await fetchStandings(code).then(data => setLeagues(data.leagues))
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '加载失败')
|
||||||
|
} finally {
|
||||||
|
setSwitching(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = leagues.find(l => l.league_code === activeLeague) ?? leagues[0]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 联赛切换 */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{LEAGUES.map(l => (
|
||||||
|
<button
|
||||||
|
key={l.code}
|
||||||
|
onClick={() => switchLeague(l.code)}
|
||||||
|
disabled={switching}
|
||||||
|
className={`rounded border px-3 py-1.5 text-xs transition-colors disabled:opacity-50 ${
|
||||||
|
activeLeague === l.code
|
||||||
|
? 'border-ink-900 bg-ink-900 text-paper-50'
|
||||||
|
: 'border-ink-200 text-ink-500 hover:border-ink-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{l.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="border border-rose-300 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="flex justify-center py-12 text-xs text-ink-400">加载中…</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 切换联赛时的轻量加载指示 */}
|
||||||
|
{switching && !loading && (
|
||||||
|
<div className="flex items-center gap-2 border-b border-ink-200 px-1 py-2 text-xs text-ink-400">
|
||||||
|
<Spinner /> 切换联赛中…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !active && (
|
||||||
|
<div className="border-y border-ink-200 py-12 text-center">
|
||||||
|
<p className="font-serif text-sm text-ink-600">暂无积分榜数据</p>
|
||||||
|
<p className="mt-1.5 text-xs text-ink-400">
|
||||||
|
请先在管理后台「数据采集」页运行「积分榜」任务。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active && (
|
||||||
|
<div>
|
||||||
|
<div className="mb-3 flex items-center justify-between border-b border-ink-200 pb-2">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-serif text-lg font-bold text-ink-900">
|
||||||
|
{active.league_name}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-ink-400">
|
||||||
|
{active.season} 赛季 · {active.rows.length} 队
|
||||||
|
{active.retrieved_at && ` · 更新于 ${new Date(active.retrieved_at).toLocaleDateString('zh-CN')}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 积分榜表格 */}
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[640px] text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-ink-200 text-left text-ink-400">
|
||||||
|
<th className="w-8 py-2 font-medium">#</th>
|
||||||
|
<th className="py-2 font-medium">球队</th>
|
||||||
|
<th className="w-10 text-center py-2 font-medium">赛</th>
|
||||||
|
<th className="w-10 text-center py-2 font-medium">胜</th>
|
||||||
|
<th className="w-10 text-center py-2 font-medium">平</th>
|
||||||
|
<th className="w-10 text-center py-2 font-medium">负</th>
|
||||||
|
<th className="w-12 text-center py-2 font-medium">进/失</th>
|
||||||
|
<th className="w-12 text-center py-2 font-medium">净</th>
|
||||||
|
<th className="w-14 text-center py-2 font-medium">积分</th>
|
||||||
|
<th className="w-16 text-center py-2 font-medium">xG±</th>
|
||||||
|
<th className="w-20 text-center py-2 font-medium">走势</th>
|
||||||
|
<th className="w-16 text-right py-2 font-medium">区</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{active.rows.map((r: StandingRow) => (
|
||||||
|
<tr key={r.position} className="border-b border-ink-100 hover:bg-paper-100">
|
||||||
|
<td className="py-2 font-medium text-ink-700">{r.position}</td>
|
||||||
|
<td className="py-2 font-medium text-ink-900">{r.team}</td>
|
||||||
|
<td className="text-center py-2 text-ink-500">{r.played}</td>
|
||||||
|
<td className="text-center py-2 text-ink-500">{r.won}</td>
|
||||||
|
<td className="text-center py-2 text-ink-500">{r.drawn}</td>
|
||||||
|
<td className="text-center py-2 text-ink-500">{r.lost}</td>
|
||||||
|
<td className="text-center py-2 text-ink-500">{r.goals_for}/{r.goals_against}</td>
|
||||||
|
<td className={`text-center py-2 ${r.goal_diff > 0 ? 'text-emerald-600' : r.goal_diff < 0 ? 'text-rose-600' : 'text-ink-500'}`}>
|
||||||
|
{r.goal_diff > 0 ? `+${r.goal_diff}` : r.goal_diff}
|
||||||
|
</td>
|
||||||
|
<td className="text-center py-2 font-bold text-ink-900">{r.points}</td>
|
||||||
|
<td className="text-center py-2 text-ink-500">
|
||||||
|
{r.xg_for != null && r.xg_against != null
|
||||||
|
? `${(r.xg_for - r.xg_against).toFixed(1)}`
|
||||||
|
: '—'}
|
||||||
|
</td>
|
||||||
|
<td className="py-2"><div className="flex justify-center"><FormDots form={r.form} /></div></td>
|
||||||
|
<td className="py-2 text-right">{zoneBadge(r.zone)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 回到顶部按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={scrollToTop}
|
||||||
|
className={`fixed bottom-6 right-6 z-40 flex h-10 w-10 items-center justify-center rounded-full border border-ink-200 bg-paper-50 text-ink-600 shadow-lg transition-all duration-300 hover:border-ink-400 hover:text-ink-900 ${
|
||||||
|
showBackTop ? 'translate-y-0 opacity-100' : 'translate-y-4 opacity-0 pointer-events-none'
|
||||||
|
}`}
|
||||||
|
aria-label="回到顶部"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -32,6 +32,8 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
|
// 毛体草书(国内 CDN)+ 粗楷体回退
|
||||||
|
brush: ['"Liu Jian Mao Cao"', '"KaiTi"', '"STKaiti"', '"Kaiti SC"', '"楷体"', '"Songti SC"', 'serif'],
|
||||||
// 标题与比分:宋体血统,报纸版面的骨架
|
// 标题与比分:宋体血统,报纸版面的骨架
|
||||||
serif: [
|
serif: [
|
||||||
'Georgia',
|
'Georgia',
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ dependencies = [
|
|||||||
"httpx>=0.27",
|
"httpx>=0.27",
|
||||||
"alembic>=1.13",
|
"alembic>=1.13",
|
||||||
"cryptography>=42.0",
|
"cryptography>=42.0",
|
||||||
|
"croniter>=2.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -22,11 +22,45 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|||||||
migrate_plaintext_sensitive_settings,
|
migrate_plaintext_sensitive_settings,
|
||||||
)
|
)
|
||||||
from src.core.security_check import assert_security_on_startup
|
from src.core.security_check import assert_security_on_startup
|
||||||
|
from src.core.scheduler import scheduler, quality_scheduler
|
||||||
|
from src.api.routes.schedules import _run_scheduled_task
|
||||||
|
from src.data.config import BZZOIRO_LEAGUE_IDS
|
||||||
await init_db() # 验证连接,不建表
|
await init_db() # 验证连接,不建表
|
||||||
await migrate_plaintext_sensitive_settings() # 明文敏感配置 → 加密(幂等)
|
await migrate_plaintext_sensitive_settings() # 明文敏感配置 → 加密(幂等)
|
||||||
await ensure_admin_password_hashed() # .env 明文密码 → scrypt 哈希(幂等)
|
await ensure_admin_password_hashed() # .env 明文密码 → scrypt 哈希(幂等)
|
||||||
await assert_security_on_startup() # 启动安全校验(生产拒绝/开发警告)
|
await assert_security_on_startup() # 启动安全校验(生产拒绝/开发警告)
|
||||||
|
|
||||||
|
# 注册默认定时任务(如果数据库中没有)
|
||||||
|
from src.db.base import AsyncSessionLocal
|
||||||
|
from sqlalchemy import select
|
||||||
|
from src.db.models import Schedule
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
existing = (await session.execute(select(Schedule.id))).scalars().all()
|
||||||
|
if "daily-events" not in existing:
|
||||||
|
session.add(Schedule(id="daily-events", task="events", cron="0 8 * * *", enabled=False))
|
||||||
|
if "daily-standings" not in existing:
|
||||||
|
session.add(Schedule(id="daily-standings", task="standings", cron="0 9 * * *", enabled=False))
|
||||||
|
if "daily-stats" not in existing:
|
||||||
|
session.add(Schedule(id="daily-stats", task="stats", cron="*/30 * * * *", enabled=False))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
# 从数据库加载所有启用的定时任务
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
schedules = (await session.execute(select(Schedule).where(Schedule.enabled))).scalars().all()
|
||||||
|
for s in schedules:
|
||||||
|
leagues = s.leagues.split(",") if s.leagues else list(BZZOIRO_LEAGUE_IDS.keys())
|
||||||
|
scheduler.register(
|
||||||
|
s.id, s.cron,
|
||||||
|
lambda sid=s.id: _run_scheduled_task(sid),
|
||||||
|
enabled=s.enabled,
|
||||||
|
)
|
||||||
|
|
||||||
|
await scheduler.start()
|
||||||
|
await quality_scheduler.start()
|
||||||
|
logger.info("应用启动完成")
|
||||||
yield
|
yield
|
||||||
|
await scheduler.stop()
|
||||||
|
await quality_scheduler.stop()
|
||||||
await close_client()
|
await close_client()
|
||||||
|
|
||||||
|
|
||||||
@@ -62,6 +96,7 @@ def create_app() -> FastAPI:
|
|||||||
from src.api.routes.backtest import router as backtest_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.auth import router as auth_router
|
||||||
from src.api.routes.admin_settings import router as admin_settings_router
|
from src.api.routes.admin_settings import router as admin_settings_router
|
||||||
|
from src.api.routes.schedules import router as schedules_router
|
||||||
|
|
||||||
app.include_router(matches_router)
|
app.include_router(matches_router)
|
||||||
app.include_router(predict_router)
|
app.include_router(predict_router)
|
||||||
@@ -70,6 +105,7 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(backtest_router)
|
app.include_router(backtest_router)
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(admin_settings_router)
|
app.include_router(admin_settings_router)
|
||||||
|
app.include_router(schedules_router)
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health():
|
async def health():
|
||||||
|
|||||||
@@ -192,3 +192,8 @@ async def rate_limit_predict(request: Request) -> None:
|
|||||||
status_code=429,
|
status_code=429,
|
||||||
detail="请求过于频繁,请稍后再试(每分钟最多 10 次)",
|
detail="请求过于频繁,请稍后再试(每分钟最多 10 次)",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_predict_rate_limit_remaining(request: Request) -> int:
|
||||||
|
"""查询当前 IP 剩余的预测配额(用于响应中提示前端)。"""
|
||||||
|
return _predict_limiter.remaining(get_client_ip(request))
|
||||||
|
|||||||
@@ -28,33 +28,22 @@ from src.core.runtime_config import (
|
|||||||
set_runtime_value,
|
set_runtime_value,
|
||||||
)
|
)
|
||||||
from src.db.base import AsyncSession, get_db_read
|
from src.db.base import AsyncSession, get_db_read
|
||||||
from src.db.models import Injury, Match, MatchStats
|
from src.db.models import League, Match, MatchStats, Standing
|
||||||
|
from src.data.key_ring import get_key_ring, parse_keys
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/admin", tags=["admin"], dependencies=[Depends(require_admin)])
|
router = APIRouter(prefix="/api/v1/admin", tags=["admin"], dependencies=[Depends(require_admin)])
|
||||||
|
|
||||||
# ── 数据源元数据 ────────────────────────────────────────────────
|
# ── 数据源元数据(bzzoiro 单一数据源) ────────────────────────────
|
||||||
|
|
||||||
_SOURCES: list[dict] = [
|
_SOURCES: list[dict] = [
|
||||||
{
|
{
|
||||||
"name": "bzzoiro",
|
"name": "bzzoiro",
|
||||||
"label": "Bzzoiro",
|
"label": "Bzzoiro",
|
||||||
"description": "历史赛程与比分数据,覆盖全球主要联赛",
|
"description": "唯一数据源:赛程比分 + 积分榜 + 比赛详细统计(xG/射门/控球等)",
|
||||||
"setting_keys": ["BZZOIRO_KEY", "BZZOIRO_BASE"],
|
"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"],
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -63,9 +52,7 @@ class SettingUpdateIn(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
async def _last_ingestion(db: AsyncSession, source: str) -> datetime | None:
|
async def _last_ingestion(db: AsyncSession, source: str) -> datetime | None:
|
||||||
"""各源最近一次采集时间(取自数据血缘字段,无记录返回 None)。"""
|
"""源最近一次采集时间(取自数据血缘字段,无记录返回 None)。"""
|
||||||
if source == "injuries":
|
|
||||||
return (await db.execute(select(func.max(Injury.retrieved_at)))).scalar()
|
|
||||||
return (
|
return (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
select(func.max(MatchStats.retrieved_at)).where(MatchStats.source == source)
|
select(func.max(MatchStats.retrieved_at)).where(MatchStats.source == source)
|
||||||
@@ -303,20 +290,22 @@ async def test_datasource(name: str):
|
|||||||
params={"date_from": today, "date_to": today},
|
params={"date_from": today, "date_to": today},
|
||||||
)
|
)
|
||||||
|
|
||||||
if name == "understat":
|
raise HTTPException(404, f"未知数据源: {name}")
|
||||||
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")
|
@router.post("/llm/ping")
|
||||||
if not api_key:
|
async def llm_ping():
|
||||||
return {"ok": False, "status": None, "latency_ms": 0, "detail": "API_FOOTBALL_KEY 未配置"}
|
"""LLM 连通性测试(不依赖比赛)。只发一次 chat 请求验证配置。"""
|
||||||
return await _probe(
|
from src.llm.provider import get_default_provider
|
||||||
"https://v3.football.api-sports.io/status",
|
p = await get_default_provider()
|
||||||
headers={"x-apisports-key": api_key},
|
resp = await p.chat(
|
||||||
|
system="你是测试助手。",
|
||||||
|
user="ping",
|
||||||
|
max_tokens=10,
|
||||||
)
|
)
|
||||||
|
if resp.error:
|
||||||
|
return {"ok": False, "message": resp.error}
|
||||||
|
return {"ok": True, "message": "LLM 连接正常", "model": p.model}
|
||||||
|
|
||||||
|
|
||||||
# ── 数据源健康/最近采集状态(只读,不触发采集) ──────────────────────
|
# ── 数据源健康/最近采集状态(只读,不触发采集) ──────────────────────
|
||||||
@@ -324,16 +313,12 @@ async def test_datasource(name: str):
|
|||||||
|
|
||||||
@router.get("/ingest/status")
|
@router.get("/ingest/status")
|
||||||
async def ingest_status(db: AsyncSession = Depends(get_db_read)):
|
async def ingest_status(db: AsyncSession = Depends(get_db_read)):
|
||||||
"""各数据源采集健康概览(只读,不触发任何采集)。
|
"""数据源采集健康概览(bzzoiro 单源;只读,不触发任何采集)。"""
|
||||||
|
|
||||||
返回尽量可得的信息;基于现有表近似的数据会标明 approximation。
|
|
||||||
失败追踪目前依赖系统日志缓冲,无专用采集失败表。
|
|
||||||
"""
|
|
||||||
# ── bzzoiro: 落库目标是 matches 表,无专用采集时间戳 ──
|
|
||||||
# 近似:以 matches 表最大 match_date(已覆盖的最远比赛日) 与 created_at 作为参考
|
|
||||||
bzzoiro_key = await get_runtime_value("BZZOIRO_KEY")
|
bzzoiro_key = await get_runtime_value("BZZOIRO_KEY")
|
||||||
bzzoiro_base = await get_runtime_value("BZZOIRO_BASE")
|
bzzoiro_base = await get_runtime_value("BZZOIRO_BASE")
|
||||||
row = (
|
|
||||||
|
# 比赛覆盖
|
||||||
|
match_row = (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
select(
|
select(
|
||||||
func.count().label("cnt"),
|
func.count().label("cnt"),
|
||||||
@@ -342,68 +327,63 @@ async def ingest_status(db: AsyncSession = Depends(get_db_read)):
|
|||||||
).where(Match.match_status == "finished")
|
).where(Match.match_status == "finished")
|
||||||
)
|
)
|
||||||
).one()
|
).one()
|
||||||
|
# 统计覆盖(精确 retrieved_at)
|
||||||
|
stats_row = (
|
||||||
|
await db.execute(
|
||||||
|
select(
|
||||||
|
func.count().label("cnt"),
|
||||||
|
func.max(MatchStats.retrieved_at).label("latest_retrieved"),
|
||||||
|
).where(MatchStats.source == "bzzoiro")
|
||||||
|
)
|
||||||
|
).one()
|
||||||
|
# 积分榜覆盖
|
||||||
|
standings_row = (
|
||||||
|
await db.execute(select(func.count()).select_from(Standing))
|
||||||
|
).scalar()
|
||||||
|
|
||||||
bzzoiro = {
|
bzzoiro = {
|
||||||
"name": "bzzoiro",
|
"name": "bzzoiro",
|
||||||
"label": "Bzzoiro",
|
"label": "Bzzoiro",
|
||||||
"key_configured": bool(bzzoiro_key),
|
"key_configured": bool(bzzoiro_key),
|
||||||
"base_url": (bzzoiro_base.rstrip("/") if bzzoiro_base else None) or settings.BZZOIRO_BASE,
|
"base_url": (bzzoiro_base.rstrip("/") if bzzoiro_base else None) or settings.BZZOIRO_BASE,
|
||||||
"reachable": None, # 不主动探测
|
"reachable": None, # 不主动探测
|
||||||
"last_success_at": row.latest_row_at.isoformat() if row.latest_row_at else None,
|
"last_success_at": (stats_row.latest_retrieved or match_row.latest_row_at),
|
||||||
"latest_match_date": row.latest_match_date.isoformat() if row.latest_match_date else None,
|
"last_success_at_iso": (
|
||||||
"recent_count": row.cnt or 0,
|
stats_row.latest_retrieved or match_row.latest_row_at
|
||||||
"note": "approx:基于 matches.finished 表,last_success_at 为行写入时间而非精确采集完成时间",
|
).isoformat() if (stats_row.latest_retrieved or match_row.latest_row_at) else None,
|
||||||
|
"latest_match_date": match_row.latest_match_date.isoformat() if match_row.latest_match_date else None,
|
||||||
|
"recent_count": match_row.cnt or 0,
|
||||||
|
"stats_count": stats_row.cnt or 0,
|
||||||
|
"standings_count": standings_row or 0,
|
||||||
|
"note": "last_success_at 取 match_stats.retrieved_at(统计回填)与 matches.created_at(比赛行)的较大者",
|
||||||
"last_failure": _last_failure_log("bzzoiro"),
|
"last_failure": _last_failure_log("bzzoiro"),
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── understat: 落库到 match_stats(source=understat),有精确 retrieved_at ──
|
return {"sources": [bzzoiro]}
|
||||||
row = (
|
|
||||||
await db.execute(
|
|
||||||
select(
|
|
||||||
func.count().label("cnt"),
|
|
||||||
func.max(MatchStats.retrieved_at).label("latest_retrieved"),
|
|
||||||
).where(MatchStats.source == "understat")
|
|
||||||
)
|
|
||||||
).one()
|
|
||||||
understat = {
|
|
||||||
"name": "understat",
|
|
||||||
"label": "Understat",
|
|
||||||
"key_configured": True, # 无需 Key
|
|
||||||
"reachable": None,
|
|
||||||
"last_success_at": row.latest_retrieved.isoformat() if row.latest_retrieved else None,
|
|
||||||
"recent_count": row.cnt or 0,
|
|
||||||
"note": "基于 match_stats.source=understat 的 retrieved_at",
|
|
||||||
"last_failure": _last_failure_log("understat"),
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── injuries: 落库到 injuries 表,有精确 retrieved_at;区分 Key/无数据/有数据 ──
|
|
||||||
api_key = await get_runtime_value("API_FOOTBALL_KEY")
|
|
||||||
row = (
|
|
||||||
await db.execute(
|
|
||||||
select(
|
|
||||||
func.count().label("cnt"),
|
|
||||||
func.max(Injury.retrieved_at).label("latest_retrieved"),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
).one()
|
|
||||||
if not api_key:
|
|
||||||
injuries_status, injuries_note = "key_not_configured", "API_FOOTBALL_KEY 未配置"
|
|
||||||
elif not row.cnt:
|
|
||||||
injuries_status, injuries_note = "no_data", "本地无伤停数据,请先采集"
|
|
||||||
else:
|
|
||||||
injuries_status, injuries_note = "has_data", f"共 {row.cnt} 条伤停记录"
|
|
||||||
injuries = {
|
|
||||||
"name": "injuries",
|
|
||||||
"label": "Injuries (API-Football)",
|
|
||||||
"key_configured": bool(api_key),
|
|
||||||
"reachable": None,
|
|
||||||
"status": injuries_status,
|
|
||||||
"last_success_at": row.latest_retrieved.isoformat() if row.latest_retrieved else None,
|
|
||||||
"recent_count": row.cnt or 0,
|
|
||||||
"note": injuries_note,
|
|
||||||
"last_failure": _last_failure_log("injuries"),
|
|
||||||
}
|
|
||||||
|
|
||||||
return {"sources": [bzzoiro, understat, injuries]}
|
@router.get("/keyring/status")
|
||||||
|
async def keyring_status():
|
||||||
|
"""KeyRing 运行状态:当前使用的 key、冷却状态、轮转信息(供管理后台展示)。"""
|
||||||
|
base = (await get_runtime_value("BZZOIRO_BASE")).rstrip("/")
|
||||||
|
raw_keys = await get_runtime_value("BZZOIRO_KEY")
|
||||||
|
ring = get_key_ring(base, raw_keys)
|
||||||
|
st = ring.stats()
|
||||||
|
st["base_url"] = base
|
||||||
|
st["cooldown_seconds"] = ring._cooldown
|
||||||
|
st["has_multiple"] = ring.has_multiple
|
||||||
|
st["active_key"] = ring.active_key
|
||||||
|
return st
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/keyring/cooldown/reset")
|
||||||
|
async def keyring_reset_cooldown():
|
||||||
|
"""手动重置所有 key 的冷却状态(用于紧急恢复)。"""
|
||||||
|
base = (await get_runtime_value("BZZOIRO_BASE")).rstrip("/")
|
||||||
|
raw_keys = await get_runtime_value("BZZOIRO_KEY")
|
||||||
|
ring = get_key_ring(base, raw_keys)
|
||||||
|
ring._blocked_until.clear()
|
||||||
|
return {"ok": True, "message": "已重置所有 key 冷却状态", "stats": ring.stats()}
|
||||||
|
|
||||||
|
|
||||||
def _last_failure_log(source: str) -> dict | None:
|
def _last_failure_log(source: str) -> dict | None:
|
||||||
@@ -422,9 +402,9 @@ def _last_failure_log(source: str) -> dict | None:
|
|||||||
|
|
||||||
@router.get("/stats")
|
@router.get("/stats")
|
||||||
async def admin_stats(db: AsyncSession = Depends(get_db_read)):
|
async def admin_stats(db: AsyncSession = Depends(get_db_read)):
|
||||||
"""管理区统计(只读):最近预测次数。轻量聚合,无 LLM 调用。"""
|
"""管理区统计(只读):预测次数 + 比赛覆盖。轻量聚合,无 LLM 调用。"""
|
||||||
from sqlalchemy import func, text
|
from sqlalchemy import func, text
|
||||||
from src.db.models import Prediction
|
from src.db.models import Prediction, Match, MatchStats, Standing
|
||||||
day_ago = datetime.now(timezone.utc) - timedelta(days=1)
|
day_ago = datetime.now(timezone.utc) - timedelta(days=1)
|
||||||
week_ago = datetime.now(timezone.utc) - timedelta(days=7)
|
week_ago = datetime.now(timezone.utc) - timedelta(days=7)
|
||||||
r = (
|
r = (
|
||||||
@@ -436,4 +416,253 @@ async def admin_stats(db: AsyncSession = Depends(get_db_read)):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
).one()
|
).one()
|
||||||
return {"predictions": {"total": r.total, "last_24h": r.last_24h, "last_7d": r.last_7d}}
|
# F3 修复: 补充真实比赛计数(非 limit=100 近似)
|
||||||
|
match_cnt = (await db.execute(select(func.count()).select_from(Match))).scalar() or 0
|
||||||
|
finished_cnt = (await db.execute(select(func.count()).where(Match.match_status == "finished"))).scalar() or 0
|
||||||
|
stats_cnt = (await db.execute(select(func.count()).select_from(MatchStats))).scalar() or 0
|
||||||
|
standings_cnt = (await db.execute(select(func.count()).select_from(Standing))).scalar() or 0
|
||||||
|
return {
|
||||||
|
"predictions": {"total": r.total, "last_24h": r.last_24h, "last_7d": r.last_7d},
|
||||||
|
"matches": {"total": match_cnt, "finished": finished_cnt},
|
||||||
|
"stats": {"total": stats_cnt},
|
||||||
|
"standings": {"total": standings_cnt},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 数据完整性分析(可视化数据源) ────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/data-completeness")
|
||||||
|
async def data_completeness(db: AsyncSession = Depends(get_db_read)):
|
||||||
|
"""按联赛统计数据完整性:比赛覆盖、字段覆盖、积分榜覆盖。
|
||||||
|
|
||||||
|
前端「数据完整性」页据此渲染,回答三个问题:
|
||||||
|
1. 数据是否齐全(各联赛比赛/统计/积分榜量级)
|
||||||
|
2. 字段是否齐全(每张统计表各字段非空率)
|
||||||
|
3. 覆盖是否新鲜(最近一场/最近一次采集)
|
||||||
|
"""
|
||||||
|
from src.data.config import BZZOIRO_LEAGUE_IDS, LEAGUE_NAMES, LEAGUE_COUNTRIES
|
||||||
|
|
||||||
|
out_leagues: list[dict] = []
|
||||||
|
for code, bzz_id in BZZOIRO_LEAGUE_IDS.items():
|
||||||
|
# 比赛覆盖
|
||||||
|
m = (
|
||||||
|
await db.execute(
|
||||||
|
select(
|
||||||
|
func.count().label("total"),
|
||||||
|
func.count().filter(Match.match_status == "finished").label("finished"),
|
||||||
|
func.count().filter(Match.match_status == "scheduled").label("scheduled"),
|
||||||
|
func.count().filter(Match.source_event_id.is_not(None)).label("with_source_id"),
|
||||||
|
func.max(Match.match_date).label("latest_match"),
|
||||||
|
func.min(Match.match_date).label("earliest_match"),
|
||||||
|
)
|
||||||
|
.select_from(Match)
|
||||||
|
.join(League, League.id == Match.league_id)
|
||||||
|
.where(League.code == code)
|
||||||
|
)
|
||||||
|
).one()
|
||||||
|
# 统计字段覆盖(联表 matches)
|
||||||
|
s = (
|
||||||
|
await db.execute(
|
||||||
|
select(
|
||||||
|
func.count().label("rows"),
|
||||||
|
func.count(MatchStats.home_xg).label("xg"),
|
||||||
|
func.count(MatchStats.home_shots).label("shots"),
|
||||||
|
func.count(MatchStats.home_possession).label("possession"),
|
||||||
|
func.count(MatchStats.home_corners).label("corners"),
|
||||||
|
func.count(MatchStats.home_fouls).label("fouls"),
|
||||||
|
func.count(MatchStats.home_big_chances).label("big_chances"),
|
||||||
|
func.count(MatchStats.home_yellow_cards).label("cards"),
|
||||||
|
)
|
||||||
|
.select_from(MatchStats)
|
||||||
|
.join(Match, Match.id == MatchStats.match_id)
|
||||||
|
.join(League, League.id == Match.league_id)
|
||||||
|
.where(League.code == code)
|
||||||
|
)
|
||||||
|
).one()
|
||||||
|
# 积分榜覆盖
|
||||||
|
st = (
|
||||||
|
await db.execute(
|
||||||
|
select(
|
||||||
|
func.count().label("rows"),
|
||||||
|
func.max(Standing.retrieved_at).label("latest_retrieved"),
|
||||||
|
)
|
||||||
|
.select_from(Standing)
|
||||||
|
.join(League, League.id == Standing.league_id)
|
||||||
|
.where(League.code == code)
|
||||||
|
)
|
||||||
|
).one()
|
||||||
|
|
||||||
|
stats_rows = s.rows or 0
|
||||||
|
pct = lambda n: round(n / stats_rows * 100, 1) if stats_rows else 0.0 # noqa: E731
|
||||||
|
out_leagues.append(
|
||||||
|
{
|
||||||
|
"code": code,
|
||||||
|
"name": LEAGUE_NAMES.get(code, code),
|
||||||
|
"country": LEAGUE_COUNTRIES.get(code),
|
||||||
|
"matches": {
|
||||||
|
"total": m.total or 0,
|
||||||
|
"finished": m.finished or 0,
|
||||||
|
"scheduled": m.scheduled or 0,
|
||||||
|
"with_source_id": m.with_source_id or 0,
|
||||||
|
"earliest_match": m.earliest_match.isoformat() if m.earliest_match else None,
|
||||||
|
"latest_match": m.latest_match.isoformat() if m.latest_match else None,
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"rows": stats_rows,
|
||||||
|
"fields": {
|
||||||
|
"xg": {"count": s.xg or 0, "pct": pct(s.xg or 0)},
|
||||||
|
"shots": {"count": s.shots or 0, "pct": pct(s.shots or 0)},
|
||||||
|
"possession": {"count": s.possession or 0, "pct": pct(s.possession or 0)},
|
||||||
|
"corners": {"count": s.corners or 0, "pct": pct(s.corners or 0)},
|
||||||
|
"fouls": {"count": s.fouls or 0, "pct": pct(s.fouls or 0)},
|
||||||
|
"big_chances": {"count": s.big_chances or 0, "pct": pct(s.big_chances or 0)},
|
||||||
|
"cards": {"count": s.cards or 0, "pct": pct(s.cards or 0)},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"standings": {
|
||||||
|
"rows": st.rows or 0,
|
||||||
|
"latest_retrieved": st.latest_retrieved.isoformat() if st.latest_retrieved else None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 整体健康信号
|
||||||
|
total_finished = sum(l["matches"]["finished"] for l in out_leagues)
|
||||||
|
total_stats = sum(l["stats"]["rows"] for l in out_leagues)
|
||||||
|
stats_coverage = round(total_stats / total_finished * 100, 1) if total_finished else 0.0
|
||||||
|
issues: list[str] = []
|
||||||
|
for l in out_leagues:
|
||||||
|
if l["matches"]["finished"] == 0:
|
||||||
|
issues.append(f"{l['name']}: 无已完赛比赛,请先运行「比赛数据」采集")
|
||||||
|
elif l["stats"]["rows"] == 0:
|
||||||
|
issues.append(f"{l['name']}: 已完赛 {l['matches']['finished']} 场但无统计回填,请运行「统计回填」采集")
|
||||||
|
elif stats_coverage < 80:
|
||||||
|
issues.append(f"{l['name']}: 统计覆盖率仅 {stats_coverage}%,建议增量回填")
|
||||||
|
if l["standings"]["rows"] == 0:
|
||||||
|
issues.append(f"{l['name']}: 无积分榜数据,请运行「积分榜」采集")
|
||||||
|
if not issues:
|
||||||
|
issues.append("各联赛数据完整度良好")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"leagues": out_leagues,
|
||||||
|
"totals": {
|
||||||
|
"finished_matches": total_finished,
|
||||||
|
"stats_rows": total_stats,
|
||||||
|
"stats_coverage_pct": stats_coverage,
|
||||||
|
},
|
||||||
|
"issues": issues,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 数据质量检查 API ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/data-quality")
|
||||||
|
async def data_quality_checks(db: AsyncSession = Depends(get_db_read)):
|
||||||
|
"""数据质量检查结果(只读)。"""
|
||||||
|
from src.db.models import IngestFailure, DataQualityCheck
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
|
# 最近的失败记录
|
||||||
|
failures = (
|
||||||
|
await db.execute(
|
||||||
|
select(IngestFailure)
|
||||||
|
.where(IngestFailure.status.in_(["pending", "retrying"]))
|
||||||
|
.order_by(IngestFailure.created_at.desc())
|
||||||
|
.limit(20)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
|
# 最近的质量检查
|
||||||
|
checks = (
|
||||||
|
await db.execute(
|
||||||
|
select(DataQualityCheck)
|
||||||
|
.order_by(DataQualityCheck.checked_at.desc())
|
||||||
|
.limit(20)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"failures": [
|
||||||
|
{
|
||||||
|
"id": f.id,
|
||||||
|
"source": f.source_system,
|
||||||
|
"entity_type": f.entity_type,
|
||||||
|
"source_record_id": f.source_record_id,
|
||||||
|
"error_type": f.error_type,
|
||||||
|
"error_detail": f.error_detail,
|
||||||
|
"retry_count": f.retry_count,
|
||||||
|
"status": f.status,
|
||||||
|
"created_at": f.created_at.isoformat() if f.created_at else None,
|
||||||
|
}
|
||||||
|
for f in failures
|
||||||
|
],
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"id": c.id,
|
||||||
|
"check_name": c.check_name,
|
||||||
|
"entity_type": c.entity_type,
|
||||||
|
"passed": c.passed,
|
||||||
|
"severity": c.severity,
|
||||||
|
"detail": c.detail,
|
||||||
|
"checked_at": c.checked_at.isoformat() if c.checked_at else None,
|
||||||
|
}
|
||||||
|
for c in checks
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/data-quality/run")
|
||||||
|
async def run_data_quality_check(db: AsyncSession = Depends(get_db_read)):
|
||||||
|
"""手动触发一次数据质量检查。"""
|
||||||
|
from src.db.models import DataQualityCheck, Match, MatchStats, Standing, League
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
|
checks = []
|
||||||
|
|
||||||
|
# 检查1: 已完赛但无统计的比赛
|
||||||
|
finished_no_stats = (
|
||||||
|
await db.execute(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(Match)
|
||||||
|
.outerjoin(MatchStats, Match.id == MatchStats.match_id)
|
||||||
|
.where(Match.match_status == "finished")
|
||||||
|
.where(MatchStats.id.is_(None))
|
||||||
|
)
|
||||||
|
).scalar() or 0
|
||||||
|
|
||||||
|
checks.append(DataQualityCheck(
|
||||||
|
check_name="finished_without_stats",
|
||||||
|
entity_type="match",
|
||||||
|
actual_value=float(finished_no_stats),
|
||||||
|
passed=finished_no_stats == 0,
|
||||||
|
severity="warning" if finished_no_stats > 0 else "info",
|
||||||
|
detail={"message": f"{finished_no_stats} 场已完赛比赛缺少统计数据"},
|
||||||
|
))
|
||||||
|
|
||||||
|
# 检查2: 积分榜缺失的联赛
|
||||||
|
leagues_without_standings = (
|
||||||
|
await db.execute(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(League)
|
||||||
|
.outerjoin(Standing, League.id == Standing.league_id)
|
||||||
|
.where(Standing.id.is_(None))
|
||||||
|
)
|
||||||
|
).scalar() or 0
|
||||||
|
|
||||||
|
checks.append(DataQualityCheck(
|
||||||
|
check_name="league_without_standings",
|
||||||
|
entity_type="league",
|
||||||
|
actual_value=float(leagues_without_standings),
|
||||||
|
passed=leagues_without_standings == 0,
|
||||||
|
severity="warning" if leagues_without_standings > 0 else "info",
|
||||||
|
detail={"message": f"{leagues_without_standings} 个联赛缺少积分榜"},
|
||||||
|
))
|
||||||
|
|
||||||
|
for c in checks:
|
||||||
|
db.add(c)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {"ok": True, "checks": [{"name": c.check_name, "passed": c.passed} for c in checks]}
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ async def login(body: LoginIn, request: Request, response: Response):
|
|||||||
raise HTTPException(status_code=401, detail="密码错误")
|
raise HTTPException(status_code=401, detail="密码错误")
|
||||||
|
|
||||||
_fail_times.pop(ip, None)
|
_fail_times.pop(ip, None)
|
||||||
|
# Code Review High-4: 生产环境(HHTTPS)下 Cookie 必须带 Secure,防中间人窃取
|
||||||
|
secure = settings.APP_ENV == "production"
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
key=SESSION_COOKIE,
|
key=SESSION_COOKIE,
|
||||||
value=create_session_token(await get_session_secret()),
|
value=create_session_token(await get_session_secret()),
|
||||||
@@ -90,6 +92,7 @@ async def login(body: LoginIn, request: Request, response: Response):
|
|||||||
httponly=True,
|
httponly=True,
|
||||||
samesite="lax",
|
samesite="lax",
|
||||||
path="/",
|
path="/",
|
||||||
|
secure=secure,
|
||||||
)
|
)
|
||||||
logger.info("管理员登录成功 (ip=%s)", ip)
|
logger.info("管理员登录成功 (ip=%s)", ip)
|
||||||
return {"ok": True, "expires_in_hours": settings.ADMIN_SESSION_TTL_HOURS}
|
return {"ok": True, "expires_in_hours": settings.ADMIN_SESSION_TTL_HOURS}
|
||||||
|
|||||||
+61
-87
@@ -1,4 +1,11 @@
|
|||||||
"""采集路由。"""
|
"""采集路由(bzzoiro 单一数据源)。
|
||||||
|
|
||||||
|
任务类型:
|
||||||
|
events — 比赛日程/比分(/events/)
|
||||||
|
standings — 联赛积分榜(/leagues/{id}/standings/)
|
||||||
|
stats — 已完赛比赛详细统计回填(/events/{id}/stats/)
|
||||||
|
all — 依次执行以上三项
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -7,10 +14,10 @@ import logging
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
from src.api.deps import require_admin
|
from src.api.deps import require_admin
|
||||||
from src.api.schemas import IngestBzzoiroRequest, IngestResponse, IngestUnderstatRequest, IngestInjuriesRequest, IngestSimpleResponse
|
from src.api.schemas import IngestBzzoiroRequest
|
||||||
from src.data.config import BZZOIRO_LEAGUE_IDS, FDCO_TO_UNDERSTAT
|
from src.data.config import BZZOIRO_LEAGUE_IDS
|
||||||
|
from src.data.bzzoiro import ingest_bzzoiro_event_stats, ingest_bzzoiro_standings
|
||||||
from src.data.sources import get_source
|
from src.data.sources import get_source
|
||||||
from src.data.injuries import ingest_injuries
|
|
||||||
from src.db.unit_of_work import get_uow
|
from src.db.unit_of_work import get_uow
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -20,6 +27,8 @@ router = APIRouter(prefix="/api/v1", tags=["ingest"])
|
|||||||
# 后台采集任务注册表:持强引用防止被 GC
|
# 后台采集任务注册表:持强引用防止被 GC
|
||||||
_background_tasks: set[asyncio.Task] = set()
|
_background_tasks: set[asyncio.Task] = set()
|
||||||
|
|
||||||
|
VALID_TASKS = {"events", "standings", "stats", "all"}
|
||||||
|
|
||||||
|
|
||||||
def _spawn(coro) -> None:
|
def _spawn(coro) -> None:
|
||||||
"""启动后台采集任务;异常已在任务内记录到系统日志。"""
|
"""启动后台采集任务;异常已在任务内记录到系统日志。"""
|
||||||
@@ -30,96 +39,61 @@ def _spawn(coro) -> None:
|
|||||||
|
|
||||||
@router.post("/ingest/bzzoiro", dependencies=[Depends(require_admin)])
|
@router.post("/ingest/bzzoiro", dependencies=[Depends(require_admin)])
|
||||||
async def ingest_bzzoiro_route(req: IngestBzzoiroRequest):
|
async def ingest_bzzoiro_route(req: IngestBzzoiroRequest):
|
||||||
"""触发 bzzoiro 采集。"""
|
"""触发 bzzoiro 采集(events / standings / stats / all)。"""
|
||||||
# 未指定联赛 = 采集全部已知联赛;未指定状态 = 已完赛 + 未开赛都采集
|
if req.task not in VALID_TASKS:
|
||||||
|
raise HTTPException(status_code=422, detail=f"未知任务类型: {req.task}(可选: {', '.join(sorted(VALID_TASKS))})")
|
||||||
leagues = req.leagues or list(BZZOIRO_LEAGUE_IDS.keys())
|
leagues = req.leagues or list(BZZOIRO_LEAGUE_IDS.keys())
|
||||||
statuses = [req.status] if req.status else ["finished", "scheduled"]
|
task_label = {"events": "比赛数据", "standings": "积分榜", "stats": "统计回填", "all": "全量(比赛+积分榜+统计)"}[req.task]
|
||||||
_spawn(_run_bzzoiro(leagues, req.date_from, req.date_to, statuses))
|
_spawn(_run_bzzoiro(req.task, leagues, req))
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"message": f"采集任务已启动(后台执行,状态: {', '.join(statuses)}),请在「系统日志」查看进度与结果",
|
"message": f"采集任务已启动(后台执行,任务: {task_label}),请在「系统日志」查看进度与结果",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def _run_bzzoiro(leagues: list[str], date_from: str | None, date_to: str | None, statuses: list[str]) -> None:
|
async def _run_bzzoiro(task: str, leagues: list[str], req: IngestBzzoiroRequest) -> None:
|
||||||
"""后台执行 bzzoiro 采集:上游限速时单次可能耗时数分钟,必须脱离请求生命周期。"""
|
"""后台执行 bzzoiro 采集:上游限速时单次可能耗时数分钟,必须脱离请求生命周期。"""
|
||||||
try:
|
try:
|
||||||
source = get_source("bzzoiro")
|
if task in ("events", "all"):
|
||||||
merged: dict = {"leagues": {}, "total_inserted": 0, "total_updated": 0, "errors": []}
|
statuses = [req.status] if req.status else ["finished", "scheduled"]
|
||||||
async with get_uow() as session:
|
source = get_source("bzzoiro")
|
||||||
for st in statuses:
|
merged: dict = {"leagues": {}, "total_inserted": 0, "total_updated": 0, "errors": []}
|
||||||
r = await source.ingest(
|
# D2 修复: 按联赛分批提交,避免超长事务
|
||||||
session,
|
for code in leagues:
|
||||||
leagues=leagues,
|
for st in statuses:
|
||||||
date_from=date_from,
|
async with get_uow() as session:
|
||||||
date_to=date_to,
|
r = await source.ingest(
|
||||||
status=st,
|
session, leagues=[code],
|
||||||
|
date_from=req.date_from, date_to=req.date_to, status=st,
|
||||||
|
)
|
||||||
|
merged["total_inserted"] += r.get("total_inserted", 0)
|
||||||
|
merged["total_updated"] += r.get("total_updated", 0)
|
||||||
|
merged["errors"].extend(r.get("errors", []))
|
||||||
|
acc = merged["leagues"].setdefault(code, {"inserted": 0, "updated": 0, "errors": []})
|
||||||
|
acc["inserted"] += r.get("inserted", 0)
|
||||||
|
acc["updated"] += r.get("updated", 0)
|
||||||
|
acc["errors"].extend(r.get("errors", []))
|
||||||
|
logger.info(
|
||||||
|
"bzzoiro 比赛采集完成: 新增 %d, 更新 %d, 联赛 %d 个, 状态 %s",
|
||||||
|
merged["total_inserted"], merged["total_updated"], len(merged["leagues"]), statuses,
|
||||||
|
)
|
||||||
|
if merged["errors"]:
|
||||||
|
logger.warning("bzzoiro 比赛采集错误 %d 条: %s", len(merged["errors"]), merged["errors"][:3])
|
||||||
|
|
||||||
|
if task in ("standings", "all"):
|
||||||
|
async with get_uow() as session:
|
||||||
|
r = await ingest_bzzoiro_standings(session, leagues=leagues, season=req.season)
|
||||||
|
if r["errors"]:
|
||||||
|
logger.warning("bzzoiro 积分榜采集部分失败: %s", r["errors"][:3])
|
||||||
|
else:
|
||||||
|
logger.info("bzzoiro 积分榜采集完成: upsert %d 条", r["total_upserted"])
|
||||||
|
|
||||||
|
if task in ("stats", "all"):
|
||||||
|
async with get_uow() as session:
|
||||||
|
r = await ingest_bzzoiro_event_stats(
|
||||||
|
session, leagues=leagues, limit=req.limit, only_missing=True
|
||||||
)
|
)
|
||||||
merged["total_inserted"] += r.get("total_inserted", 0)
|
if r["errors"]:
|
||||||
merged["total_updated"] += r.get("total_updated", 0)
|
logger.warning("bzzoiro 统计回填错误 %d 条: %s", len(r["errors"]), r["errors"][:3])
|
||||||
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])
|
|
||||||
logger.debug("bzzoiro 采集明细: leagues=%s", list(merged["leagues"].keys()))
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("bzzoiro 采集任务失败")
|
logger.exception("bzzoiro 采集任务失败(task=%s)", task)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/ingest/understat", dependencies=[Depends(require_admin)])
|
|
||||||
async def ingest_understat_route(req: IngestUnderstatRequest):
|
|
||||||
"""触发 understat xG 回填。"""
|
|
||||||
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:
|
|
||||||
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", 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=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 采集任务失败")
|
|
||||||
|
|||||||
+105
-3
@@ -4,17 +4,34 @@ from __future__ import annotations
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy import or_, select
|
from sqlalchemy import func, or_, select
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from src.api.deps import require_admin
|
from src.api.deps import require_admin
|
||||||
from src.api.schemas import MatchListOut, MatchOut, PredictionOut
|
from src.api.schemas import MatchListOut, MatchOut, PredictionOut
|
||||||
from src.db.base import AsyncSession, get_db_read
|
from src.db.base import AsyncSession, get_db_read
|
||||||
from src.db.models import League, Match, Prediction
|
from src.db.models import League, Match, Prediction, Standing
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1", tags=["data"])
|
router = APIRouter(prefix="/api/v1", tags=["data"])
|
||||||
|
|
||||||
|
|
||||||
|
def _stats_dict(stats) -> dict | None:
|
||||||
|
"""把 MatchStats ORM 对象序列化为前端可读的扁平 dict。"""
|
||||||
|
if stats is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"home_xg": stats.home_xg, "away_xg": stats.away_xg,
|
||||||
|
"home_shots": stats.home_shots, "away_shots": stats.away_shots,
|
||||||
|
"home_shots_on_target": stats.home_shots_on_target, "away_shots_on_target": stats.away_shots_on_target,
|
||||||
|
"home_corners": stats.home_corners, "away_corners": stats.away_corners,
|
||||||
|
"home_possession": stats.home_possession,
|
||||||
|
"home_yellow_cards": stats.home_yellow_cards, "away_yellow_cards": stats.away_yellow_cards,
|
||||||
|
"home_red_cards": stats.home_red_cards, "away_red_cards": stats.away_red_cards,
|
||||||
|
"home_big_chances": stats.home_big_chances, "away_big_chances": stats.away_big_chances,
|
||||||
|
"home_fouls": stats.home_fouls, "away_fouls": stats.away_fouls,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/leagues", response_model=list[dict], dependencies=[Depends(require_admin)])
|
@router.get("/leagues", response_model=list[dict], dependencies=[Depends(require_admin)])
|
||||||
async def list_leagues(db: AsyncSession = Depends(get_db_read)):
|
async def list_leagues(db: AsyncSession = Depends(get_db_read)):
|
||||||
stmt = select(League).order_by(League.name)
|
stmt = select(League).order_by(League.name)
|
||||||
@@ -72,7 +89,13 @@ async def list_matches(
|
|||||||
d = datetime.strptime(date, "%Y-%m-%d")
|
d = datetime.strptime(date, "%Y-%m-%d")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise HTTPException(400, "date 格式应为 YYYY-MM-DD")
|
raise HTTPException(400, "date 格式应为 YYYY-MM-DD")
|
||||||
q = q.where(Match.match_date >= d, Match.match_date < d + timedelta(days=1))
|
# date 是用户本地日期(默认北京 UTC+8);match_date 存 UTC,需转换:
|
||||||
|
# 本地 00:00 (UTC+8) = UTC 前一天 16:00;本地 24:00 = UTC 当天 16:00
|
||||||
|
from datetime import timezone as tz_mod
|
||||||
|
tz_cn = tz_mod(timedelta(hours=8))
|
||||||
|
local_start = d.replace(tzinfo=tz_cn)
|
||||||
|
local_end = local_start + timedelta(days=1)
|
||||||
|
q = q.where(Match.match_date >= local_start, Match.match_date < local_end)
|
||||||
|
|
||||||
# 未开赛按日期正序(最近的排最前,便于预测);其余按日期倒序(最新赛果在前)
|
# 未开赛按日期正序(最近的排最前,便于预测);其余按日期倒序(最新赛果在前)
|
||||||
if status == "scheduled":
|
if status == "scheduled":
|
||||||
@@ -149,6 +172,7 @@ async def get_match(match_id: int, db: AsyncSession = Depends(get_db_read)):
|
|||||||
match_stage=m.match_stage,
|
match_stage=m.match_stage,
|
||||||
home_xg=m.stats.home_xg if m.stats else None,
|
home_xg=m.stats.home_xg if m.stats else None,
|
||||||
away_xg=m.stats.away_xg if m.stats else None,
|
away_xg=m.stats.away_xg if m.stats else None,
|
||||||
|
stats=_stats_dict(m.stats) if m.stats else None,
|
||||||
recent_predictions=[
|
recent_predictions=[
|
||||||
PredictionOut(
|
PredictionOut(
|
||||||
id=p.id, match_id=p.match_id, provider=p.provider, model=p.model,
|
id=p.id, match_id=p.match_id, provider=p.provider, model=p.model,
|
||||||
@@ -240,3 +264,81 @@ async def match_context(match_id: int, db: AsyncSession = Depends(get_db_read)):
|
|||||||
"away_recent": [_row_to_dict(r) for r in away_recent],
|
"away_recent": [_row_to_dict(r) for r in away_recent],
|
||||||
"h2h": [_row_to_dict(r) for r in h2h],
|
"h2h": [_row_to_dict(r) for r in h2h],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/standings")
|
||||||
|
async def list_standings(
|
||||||
|
league: str | None = Query(None, description="联赛代码,如 E0;空 = 全部联赛"),
|
||||||
|
season: str | None = Query(None, description="赛季标签,如 2026-2027;空 = 各联赛最新赛季"),
|
||||||
|
db: AsyncSession = Depends(get_db_read),
|
||||||
|
):
|
||||||
|
"""联赛积分榜(只读)。按联赛分组,每张榜按 position 排序。
|
||||||
|
|
||||||
|
season 为空时返回每个联赛最新采集到的赛季榜单(适合前端"查看最新积分榜")。
|
||||||
|
"""
|
||||||
|
# 取每个联赛最新赛季(当 season 为空时)
|
||||||
|
latest_seasons: dict[int, str] = {}
|
||||||
|
if season is None:
|
||||||
|
rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(Standing.league_id, func.max(Standing.season).label("latest"))
|
||||||
|
.group_by(Standing.league_id)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
latest_seasons = {r.league_id: r.latest for r in rows}
|
||||||
|
|
||||||
|
q = (
|
||||||
|
select(Standing, League)
|
||||||
|
.join(League, League.id == Standing.league_id)
|
||||||
|
.order_by(League.name.asc(), Standing.position.asc())
|
||||||
|
)
|
||||||
|
if league:
|
||||||
|
q = q.where(League.code == league)
|
||||||
|
if season:
|
||||||
|
q = q.where(Standing.season == season)
|
||||||
|
else:
|
||||||
|
# 多联赛时只保留各联赛最新赛季
|
||||||
|
if latest_seasons:
|
||||||
|
q = q.where(
|
||||||
|
or_(
|
||||||
|
*(
|
||||||
|
(Standing.league_id == lid) & (Standing.season == ls)
|
||||||
|
for lid, ls in latest_seasons.items()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = (await db.execute(q)).all()
|
||||||
|
|
||||||
|
# 按联赛分组
|
||||||
|
grouped: dict[str, dict] = {}
|
||||||
|
for standing, lg in rows:
|
||||||
|
key = lg.code
|
||||||
|
if key not in grouped:
|
||||||
|
grouped[key] = {
|
||||||
|
"league_code": lg.code,
|
||||||
|
"league_name": lg.name,
|
||||||
|
"season": standing.season,
|
||||||
|
"retrieved_at": standing.retrieved_at.isoformat() if standing.retrieved_at else None,
|
||||||
|
"rows": [],
|
||||||
|
}
|
||||||
|
grouped[key]["rows"].append(
|
||||||
|
{
|
||||||
|
"position": standing.position,
|
||||||
|
"team": standing.team.name_zh or standing.team.name if standing.team else "?",
|
||||||
|
"team_en": standing.team.name if standing.team else "?",
|
||||||
|
"played": standing.played,
|
||||||
|
"won": standing.won,
|
||||||
|
"drawn": standing.drawn,
|
||||||
|
"lost": standing.lost,
|
||||||
|
"goals_for": standing.goals_for,
|
||||||
|
"goals_against": standing.goals_against,
|
||||||
|
"goal_diff": standing.goal_diff,
|
||||||
|
"points": standing.points,
|
||||||
|
"xg_for": standing.xg_for,
|
||||||
|
"xg_against": standing.xg_against,
|
||||||
|
"form": standing.form,
|
||||||
|
"zone": standing.zone,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"leagues": list(grouped.values())}
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from src.api.deps import rate_limit_predict, require_admin
|
from src.api.deps import get_predict_rate_limit_remaining, rate_limit_predict, require_admin
|
||||||
from src.api.schemas import PredictOut, PredictRequest, PredictionOut
|
from src.api.schemas import PredictOut, PredictRequest, PredictionOut
|
||||||
from src.db.base import AsyncSession, get_db_read, short_read
|
from src.db.base import AsyncSession, get_db_read, short_read
|
||||||
from src.db.models import Match, Prediction
|
from src.db.models import Match, Prediction
|
||||||
@@ -24,8 +24,8 @@ router = APIRouter(prefix="/api/v1", tags=["predict"])
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/predict", response_model=PredictOut, dependencies=[Depends(rate_limit_predict)])
|
@router.post("/predict", response_model=PredictOut, dependencies=[Depends(rate_limit_predict)])
|
||||||
async def predict(req: PredictRequest):
|
async def predict(req: PredictRequest, request: Request):
|
||||||
"""对一场比赛调 LLM 预测。mode=multi(默认,5专家+终裁)或 single。
|
"""对一场比赛调 LLM 预测。mode=multi(默认,5专家+终裁)、single 或 baseline。
|
||||||
|
|
||||||
公开接口,仅做限流保护(不要求登录)。
|
公开接口,仅做限流保护(不要求登录)。
|
||||||
|
|
||||||
@@ -100,7 +100,7 @@ async def predict(req: PredictRequest):
|
|||||||
latency_ms=result.get("latency_ms", 0) if result_dict else result.latency_ms,
|
latency_ms=result.get("latency_ms", 0) if result_dict else result.latency_ms,
|
||||||
prompt_tokens=result.get("prompt_tokens") if result_dict else getattr(result, "prompt_tokens", None),
|
prompt_tokens=result.get("prompt_tokens") if result_dict else getattr(result, "prompt_tokens", None),
|
||||||
completion_tokens=result.get("completion_tokens") if result_dict else getattr(result, "completion_tokens", None),
|
completion_tokens=result.get("completion_tokens") if result_dict else getattr(result, "completion_tokens", None),
|
||||||
rate_limit_remaining=_predict_limiter.remaining(get_client_ip(request)),
|
rate_limit_remaining=get_predict_rate_limit_remaining(request),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -116,7 +116,7 @@ async def _persist_baseline(match_id: int, baseline: dict) -> int:
|
|||||||
provider_name="baseline",
|
provider_name="baseline",
|
||||||
model="baseline",
|
model="baseline",
|
||||||
mode="baseline",
|
mode="baseline",
|
||||||
run_type="baseline",
|
run_type="live", # baseline 是 live 预测的变体,符合 ck_run_type_enum
|
||||||
values={
|
values={
|
||||||
"prompt_version": "baseline_v1",
|
"prompt_version": "baseline_v1",
|
||||||
"prompt_tokens": 0,
|
"prompt_tokens": 0,
|
||||||
@@ -167,11 +167,31 @@ async def list_predictions(
|
|||||||
actual_home_goals=p.actual_home_goals,
|
actual_home_goals=p.actual_home_goals,
|
||||||
actual_away_goals=p.actual_away_goals,
|
actual_away_goals=p.actual_away_goals,
|
||||||
settled=p.settled,
|
settled=p.settled,
|
||||||
|
match=_match_dict(p.match) if p.match else None,
|
||||||
)
|
)
|
||||||
for p in rows
|
for p in rows
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _match_dict(m) -> dict | None:
|
||||||
|
if m is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"id": m.id,
|
||||||
|
"league_code": m.league.code if m.league else None,
|
||||||
|
"season": m.season,
|
||||||
|
"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": m.home_team.name_zh if m.home_team else None,
|
||||||
|
"away_team_zh": m.away_team.name_zh if m.away_team else None,
|
||||||
|
"match_date": m.match_date.isoformat() if m.match_date else None,
|
||||||
|
"match_status": m.match_status,
|
||||||
|
"home_goals": m.home_goals,
|
||||||
|
"away_goals": m.away_goals,
|
||||||
|
"match_stage": m.match_stage,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/predictions/{prediction_id}", response_model=PredictionOut, dependencies=[Depends(require_admin)])
|
@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)):
|
async def get_prediction(prediction_id: int, db: AsyncSession = Depends(get_db_read)):
|
||||||
p = await db.get(Prediction, prediction_id)
|
p = await db.get(Prediction, prediction_id)
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
"""定时任务管理路由。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy import select, delete
|
||||||
|
|
||||||
|
from src.api.deps import require_admin
|
||||||
|
from src.api.schemas import ScheduleIn, ScheduleUpdate, ScheduleOut
|
||||||
|
from src.core.scheduler import scheduler
|
||||||
|
from src.data.bzzoiro import ingest_bzzoiro_event_stats, ingest_bzzoiro_standings
|
||||||
|
from src.data.sources import get_source
|
||||||
|
from src.data.config import BZZOIRO_LEAGUE_IDS
|
||||||
|
from src.db.base import AsyncSession, get_db_read
|
||||||
|
from src.db.models import Schedule
|
||||||
|
from src.db.unit_of_work import get_uow
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/admin", tags=["schedule"], dependencies=[Depends(require_admin)])
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_scheduled_task(schedule_id: str) -> None:
|
||||||
|
"""执行定时任务的回调函数。"""
|
||||||
|
async with get_uow() as session:
|
||||||
|
stmt = select(Schedule).where(Schedule.id == schedule_id)
|
||||||
|
sched = (await session.execute(stmt)).scalar_one_or_none()
|
||||||
|
if sched is None or not sched.enabled:
|
||||||
|
return
|
||||||
|
|
||||||
|
leagues = sched.leagues.split(",") if sched.leagues else list(BZZOIRO_LEAGUE_IDS.keys())
|
||||||
|
task = sched.task
|
||||||
|
|
||||||
|
try:
|
||||||
|
if task in ("events", "all"):
|
||||||
|
statuses = ["finished", "scheduled"]
|
||||||
|
source = get_source("bzzoiro")
|
||||||
|
for st in statuses:
|
||||||
|
await source.ingest(session, leagues=leagues, status=st)
|
||||||
|
|
||||||
|
if task in ("standings", "all"):
|
||||||
|
await ingest_bzzoiro_standings(session, leagues=leagues)
|
||||||
|
|
||||||
|
if task in ("stats", "all"):
|
||||||
|
await ingest_bzzoiro_event_stats(session, leagues=leagues, limit=500, only_missing=True)
|
||||||
|
|
||||||
|
sched.last_status = "success"
|
||||||
|
except Exception:
|
||||||
|
logger.exception("定时任务执行失败: %s", schedule_id)
|
||||||
|
sched.last_status = "failed"
|
||||||
|
finally:
|
||||||
|
sched.last_run_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_scheduler() -> None:
|
||||||
|
"""同步数据库中的调度配置到调度器。"""
|
||||||
|
# 这是一个简化版本:实际应该在 lifespan 中异步同步
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/schedules")
|
||||||
|
async def list_schedules(db: AsyncSession = Depends(get_db_read)):
|
||||||
|
"""列出所有定时任务。"""
|
||||||
|
rows = (await db.execute(select(Schedule).order_by(Schedule.created_at))).scalars().all()
|
||||||
|
return [
|
||||||
|
ScheduleOut(
|
||||||
|
id=s.id,
|
||||||
|
task=s.task,
|
||||||
|
cron=s.cron,
|
||||||
|
leagues=s.leagues,
|
||||||
|
enabled=s.enabled,
|
||||||
|
last_run_at=s.last_run_at.isoformat() if s.last_run_at else None,
|
||||||
|
last_status=s.last_status,
|
||||||
|
)
|
||||||
|
for s in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/schedules")
|
||||||
|
async def create_schedule(req: ScheduleIn, db: AsyncSession = Depends(get_db_read)):
|
||||||
|
"""创建定时任务。"""
|
||||||
|
sched = Schedule(
|
||||||
|
id=req.id,
|
||||||
|
task=req.task,
|
||||||
|
cron=req.cron,
|
||||||
|
leagues=",".join(req.leagues) if req.leagues else None,
|
||||||
|
enabled=req.enabled,
|
||||||
|
)
|
||||||
|
db.add(sched)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# 注册到调度器(注意: 第 2 参是 cron 表达式,不是 task 类型名)
|
||||||
|
scheduler.register(req.id, req.cron, lambda: _run_scheduled_task(req.id), enabled=req.enabled)
|
||||||
|
|
||||||
|
return {"ok": True, "id": req.id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/schedules/{schedule_id}")
|
||||||
|
async def update_schedule(schedule_id: str, req: ScheduleUpdate, db: AsyncSession = Depends(get_db_read)):
|
||||||
|
"""更新定时任务(部分更新)。"""
|
||||||
|
stmt = select(Schedule).where(Schedule.id == schedule_id)
|
||||||
|
sched = (await db.execute(stmt)).scalar_one_or_none()
|
||||||
|
if sched is None:
|
||||||
|
raise HTTPException(404, "定时任务不存在")
|
||||||
|
|
||||||
|
if req.task is not None:
|
||||||
|
sched.task = req.task
|
||||||
|
if req.cron is not None:
|
||||||
|
sched.cron = req.cron
|
||||||
|
if req.leagues is not None:
|
||||||
|
sched.leagues = ",".join(req.leagues) if req.leagues else None
|
||||||
|
if req.enabled is not None:
|
||||||
|
sched.enabled = req.enabled
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# 更新调度器(第 2 参传 cron 表达式;使用最终值)
|
||||||
|
scheduler.register(schedule_id, sched.cron, lambda: _run_scheduled_task(schedule_id), enabled=sched.enabled)
|
||||||
|
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/schedules/{schedule_id}")
|
||||||
|
async def delete_schedule(schedule_id: str, db: AsyncSession = Depends(get_db_read)):
|
||||||
|
"""删除定时任务。"""
|
||||||
|
await db.execute(delete(Schedule).where(Schedule.id == schedule_id))
|
||||||
|
await db.commit()
|
||||||
|
scheduler.remove(schedule_id)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/schedules/{schedule_id}/run")
|
||||||
|
async def run_schedule_now(schedule_id: str):
|
||||||
|
"""手动触发定时任务。"""
|
||||||
|
import asyncio
|
||||||
|
asyncio.create_task(_run_scheduled_task(schedule_id))
|
||||||
|
return {"ok": True, "message": "任务已启动"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 采集失败重试 ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ingest-failures")
|
||||||
|
async def list_ingest_failures(db: AsyncSession = Depends(get_db_read)):
|
||||||
|
"""列出采集失败记录。"""
|
||||||
|
from src.db.models import IngestFailure
|
||||||
|
rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(IngestFailure).order_by(IngestFailure.created_at.desc()).limit(50)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": f.id,
|
||||||
|
"source": f.source_system,
|
||||||
|
"entity_type": f.entity_type,
|
||||||
|
"source_record_id": f.source_record_id,
|
||||||
|
"error_type": f.error_type,
|
||||||
|
"error_detail": f.error_detail,
|
||||||
|
"retry_count": f.retry_count,
|
||||||
|
"status": f.status,
|
||||||
|
"next_retry_at": f.next_retry_at.isoformat() if f.next_retry_at else None,
|
||||||
|
"created_at": f.created_at.isoformat() if f.created_at else None,
|
||||||
|
}
|
||||||
|
for f in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ingest-failures/{failure_id}/retry")
|
||||||
|
async def retry_ingest_failure(failure_id: int, db: AsyncSession = Depends(get_db_read)):
|
||||||
|
"""重试一次采集失败。"""
|
||||||
|
from src.db.models import IngestFailure
|
||||||
|
stmt = select(IngestFailure).where(IngestFailure.id == failure_id)
|
||||||
|
failure = (await db.execute(stmt)).scalar_one_or_none()
|
||||||
|
if failure is None:
|
||||||
|
raise HTTPException(404, "失败记录不存在")
|
||||||
|
|
||||||
|
failure.status = "retrying"
|
||||||
|
failure.retry_count += 1
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# 触发重试(简化版:仅标记状态,实际重试逻辑由调度器处理)
|
||||||
|
return {"ok": True, "message": f"已标记重试 (第 {failure.retry_count} 次)"}
|
||||||
+27
-11
@@ -29,6 +29,8 @@ class MatchOut(BaseModel):
|
|||||||
match_stage: str | None
|
match_stage: str | None
|
||||||
home_xg: float | None = None
|
home_xg: float | None = None
|
||||||
away_xg: float | None = None
|
away_xg: float | None = None
|
||||||
|
# 比赛详细统计(bzzoiro /events/{id}/stats/),无统计为 None
|
||||||
|
stats: dict | None = None
|
||||||
# 该场比赛的最近预测摘要(按时间倒序,最多 5 条;无预测为空)
|
# 该场比赛的最近预测摘要(按时间倒序,最多 5 条;无预测为空)
|
||||||
recent_predictions: list[PredictionOut] = []
|
recent_predictions: list[PredictionOut] = []
|
||||||
|
|
||||||
@@ -46,6 +48,7 @@ class PredictRequest(BaseModel):
|
|||||||
prompt_version: str | None = None
|
prompt_version: str | None = None
|
||||||
mode: str = Field(
|
mode: str = Field(
|
||||||
"multi",
|
"multi",
|
||||||
|
pattern="^(multi|single|baseline)$",
|
||||||
description="multi(默认,5专家+终裁) | single(单次) | baseline(极简统计基线,不调用 LLM)",
|
description="multi(默认,5专家+终裁) | single(单次) | baseline(极简统计基线,不调用 LLM)",
|
||||||
)
|
)
|
||||||
use_cache: bool = True
|
use_cache: bool = True
|
||||||
@@ -100,6 +103,8 @@ class PredictionOut(BaseModel):
|
|||||||
actual_home_goals: int | None
|
actual_home_goals: int | None
|
||||||
actual_away_goals: int | None
|
actual_away_goals: int | None
|
||||||
settled: bool
|
settled: bool
|
||||||
|
# 比赛信息(可选,列表接口不返回以减少 payload)
|
||||||
|
match: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
class IngestBzzoiroRequest(BaseModel):
|
class IngestBzzoiroRequest(BaseModel):
|
||||||
@@ -107,6 +112,9 @@ class IngestBzzoiroRequest(BaseModel):
|
|||||||
date_from: str | None = None
|
date_from: str | None = None
|
||||||
date_to: str | None = None
|
date_to: str | None = None
|
||||||
status: str | None = Field(None, description="finished/scheduled;空 = 两者都采集")
|
status: str | None = Field(None, description="finished/scheduled;空 = 两者都采集")
|
||||||
|
task: str = Field("events", description="采集任务: events(比赛)/standings(积分榜)/stats(统计回填)/all")
|
||||||
|
limit: int = Field(100, ge=1, le=500, description="stats 回填单次最大比赛数")
|
||||||
|
season: str | None = Field(None, description="standings 赛季,如 '2026-2027';空 = 当前赛季")
|
||||||
|
|
||||||
|
|
||||||
class IngestResponse(BaseModel):
|
class IngestResponse(BaseModel):
|
||||||
@@ -116,21 +124,29 @@ class IngestResponse(BaseModel):
|
|||||||
errors: list[str] = []
|
errors: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
class IngestUnderstatRequest(BaseModel):
|
class ScheduleIn(BaseModel):
|
||||||
league: str | None = Field(None, description="联赛代码,如 'E0';空 = 全部已知联赛")
|
id: str = Field(..., description="任务唯一标识,如 'daily-events'")
|
||||||
season: int = Field(default_factory=lambda: date.today().year, description="赛季起始年,如 2025 表示 2025-2026 赛季")
|
task: str = Field(..., description="events / standings / stats / all")
|
||||||
|
cron: str = Field(..., description="cron 表达式,如 '0 8 * * *' (每天 8 点)")
|
||||||
|
leagues: list[str] = Field(default_factory=list, description="联赛代码列表,空=全部")
|
||||||
|
enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
class IngestInjuriesRequest(BaseModel):
|
class ScheduleUpdate(BaseModel):
|
||||||
date: str | None = Field(None, description="日期 YYYY-MM-DD,为空则采集当天")
|
task: str | None = None
|
||||||
|
cron: str | None = None
|
||||||
|
leagues: list[str] | None = None
|
||||||
|
enabled: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
class IngestSimpleResponse(BaseModel):
|
class ScheduleOut(BaseModel):
|
||||||
count: int = 0
|
id: str
|
||||||
updated: int = 0
|
task: str
|
||||||
skipped: int = 0
|
cron: str
|
||||||
unmatched: int = 0
|
leagues: str | None
|
||||||
errors: list[str] = []
|
enabled: bool
|
||||||
|
last_run_at: str | None
|
||||||
|
last_status: str | None
|
||||||
|
|
||||||
|
|
||||||
class SettleRequest(BaseModel):
|
class SettleRequest(BaseModel):
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ class Settings(BaseSettings):
|
|||||||
# --- data sources ---
|
# --- data sources ---
|
||||||
BZZOIRO_KEY: str = ""
|
BZZOIRO_KEY: str = ""
|
||||||
BZZOIRO_BASE: str = "https://sports.bzzoiro.com/api/v2"
|
BZZOIRO_BASE: str = "https://sports.bzzoiro.com/api/v2"
|
||||||
API_FOOTBALL_KEY: str = ""
|
|
||||||
|
|
||||||
# --- 代理头信任 ---
|
# --- 代理头信任 ---
|
||||||
# 为 True 时才解析 X-Forwarded-For,否则只用 request.client.host。
|
# 为 True 时才解析 X-Forwarded-For,否则只用 request.client.host。
|
||||||
|
|||||||
@@ -2,9 +2,7 @@
|
|||||||
|
|
||||||
使用方:
|
使用方:
|
||||||
- src/llm/provider.py: LLM 调用
|
- src/llm/provider.py: LLM 调用
|
||||||
- src/data/bzzoiro.py: bzzoiro 比赛数据
|
- src/data/bzzoiro.py: bzzoiro 比赛数据 / 积分榜 / 事件统计
|
||||||
- src/data/understat.py: xG 抓取
|
|
||||||
- src/data/injuries.py: 伤停抓取
|
|
||||||
|
|
||||||
生命周期由 FastAPI lifespan 管理(关闭时 aclose)。
|
生命周期由 FastAPI lifespan 管理(关闭时 aclose)。
|
||||||
调用方可通过 `timeout` 参数覆盖 per-request 超时。
|
调用方可通过 `timeout` 参数覆盖 per-request 超时。
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ DB 读取失败时也回落环境变量,保证采集不因管理表故障而中
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
@@ -39,14 +40,13 @@ class SettingDef:
|
|||||||
# 允许在后台查看/修改的配置项白名单(之外的 key 一律拒绝读写)
|
# 允许在后台查看/修改的配置项白名单(之外的 key 一律拒绝读写)
|
||||||
SETTING_DEFS: dict[str, SettingDef] = {
|
SETTING_DEFS: dict[str, SettingDef] = {
|
||||||
"BZZOIRO_KEY": SettingDef(
|
"BZZOIRO_KEY": SettingDef(
|
||||||
"BZZOIRO_KEY", "Bzzoiro API Key", "比赛赛程 / 比分数据源凭证", sensitive=True,
|
"BZZOIRO_KEY", "Bzzoiro API Key",
|
||||||
|
"比赛赛程/比分/积分榜/统计的数据源凭证。支持多 key 轮换:用逗号、分号或换行分隔多个 key,遇到限流(429)自动切换",
|
||||||
|
sensitive=True,
|
||||||
),
|
),
|
||||||
"BZZOIRO_BASE": SettingDef(
|
"BZZOIRO_BASE": SettingDef(
|
||||||
"BZZOIRO_BASE", "Bzzoiro API 地址", "Bzzoiro 接口基础地址", sensitive=False,
|
"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": SettingDef(
|
||||||
"LLM_API_KEY", "LLM API Key", "大模型服务凭证(OpenAI 兼容接口)", sensitive=True,
|
"LLM_API_KEY", "LLM API Key", "大模型服务凭证(OpenAI 兼容接口)", sensitive=True,
|
||||||
),
|
),
|
||||||
@@ -64,7 +64,7 @@ AGENT_META: list[dict] = [
|
|||||||
{"id": "form", "label": "近期状态分析专家"},
|
{"id": "form", "label": "近期状态分析专家"},
|
||||||
{"id": "stats", "label": "攻防数据分析专家"},
|
{"id": "stats", "label": "攻防数据分析专家"},
|
||||||
{"id": "home_away", "label": "主客因素分析专家"},
|
{"id": "home_away", "label": "主客因素分析专家"},
|
||||||
{"id": "injuries", "label": "阵容完整性分析专家"},
|
{"id": "standings", "label": "联赛排名分析专家"},
|
||||||
{"id": "h2h", "label": "历史交锋分析专家"},
|
{"id": "h2h", "label": "历史交锋分析专家"},
|
||||||
{"id": "aggregator", "label": "终裁分析专家"},
|
{"id": "aggregator", "label": "终裁分析专家"},
|
||||||
]
|
]
|
||||||
@@ -83,11 +83,20 @@ for _agent in AGENT_META:
|
|||||||
|
|
||||||
|
|
||||||
def mask_value(value: str, sensitive: bool) -> str:
|
def mask_value(value: str, sensitive: bool) -> str:
|
||||||
"""脱敏展示:敏感值只留末 4 位;非敏感值原样返回。"""
|
"""脱敏展示:敏感值只留末 4 位;非敏感值原样返回。
|
||||||
|
|
||||||
|
多 key(逗号/分号/换行分隔)时显示数量,如 "3 个 key(末段 …XXXX)"。
|
||||||
|
"""
|
||||||
if not value:
|
if not value:
|
||||||
return ""
|
return ""
|
||||||
if not sensitive:
|
if not sensitive:
|
||||||
return value
|
return value
|
||||||
|
# 检测多 key
|
||||||
|
keys = [k.strip() for k in re.split(r"[,;\n]", value) if k.strip()]
|
||||||
|
if len(keys) > 1:
|
||||||
|
last = keys[-1]
|
||||||
|
tail = last[-4:] if len(last) >= 4 else last
|
||||||
|
return f"{len(keys)} 个 key(末段 …{tail})"
|
||||||
return f"****{value[-4:]}" if len(value) >= 8 else "****"
|
return f"****{value[-4:]}" if len(value) >= 8 else "****"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
"""定时任务调度器:支持 cron 表达式触发采集任务。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Callable, Coroutine
|
||||||
|
|
||||||
|
from croniter import croniter
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduledTask:
|
||||||
|
"""一个定时任务。"""
|
||||||
|
|
||||||
|
#: 运行循环的轮询粒度(秒)。同时决定运行期 cron/next_run 变更的生效延迟上限。
|
||||||
|
SLEEP_TICK: float = 1.0
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
task_id: str,
|
||||||
|
cron: str,
|
||||||
|
fn: Callable[[], Coroutine],
|
||||||
|
enabled: bool = True,
|
||||||
|
) -> None:
|
||||||
|
self.task_id = task_id
|
||||||
|
self.cron = cron
|
||||||
|
self.fn = fn
|
||||||
|
self.enabled = enabled
|
||||||
|
self.last_run: datetime | None = None
|
||||||
|
self.next_run: datetime | None = None
|
||||||
|
self._task: asyncio.Task | None = None
|
||||||
|
# 构造时立即校验 cron:非法表达式直接报错,不留给 _run_loop 静默吞掉。
|
||||||
|
# (全量审查 C1: 传入任务类型字符串时 croniter 抛异常被吞 → 任务永不触发)
|
||||||
|
self._calc_next(raise_on_error=True)
|
||||||
|
|
||||||
|
def _calc_next(self, *, raise_on_error: bool = False) -> None:
|
||||||
|
"""计算下次运行时间。
|
||||||
|
|
||||||
|
raise_on_error=True 时非法 cron 抛 ValueError(构造期用);
|
||||||
|
否则仅告警并置 next_run=None(运行期容错)。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 必须调用 datetime.now():传方法对象本身会让 croniter 在
|
||||||
|
# (start_time or now) 的算术里抛 TypeError,导致 next_run 永远为 None。
|
||||||
|
self.next_run = croniter(self.cron, datetime.now()).get_next(datetime)
|
||||||
|
except Exception as e:
|
||||||
|
self.next_run = None
|
||||||
|
msg = (
|
||||||
|
f"任务 {self.task_id!r} 的 cron 表达式非法: {self.cron!r} ({e})。"
|
||||||
|
"注意: 此处应为 cron 表达式(如 '0 8 * * *'),不是任务类型名。"
|
||||||
|
)
|
||||||
|
if raise_on_error:
|
||||||
|
raise ValueError(msg) from e
|
||||||
|
logger.error(msg)
|
||||||
|
|
||||||
|
def update(self, cron: str | None = None, enabled: bool | None = None) -> None:
|
||||||
|
if cron is not None:
|
||||||
|
self.cron = cron
|
||||||
|
if enabled is not None:
|
||||||
|
self.enabled = enabled
|
||||||
|
self._calc_next()
|
||||||
|
|
||||||
|
async def _run_loop(self) -> None:
|
||||||
|
"""任务运行循环。
|
||||||
|
|
||||||
|
睡眠策略: 使用固定的短 tick(SLEEP_TICK 秒)轮询 next_run,而不是
|
||||||
|
一次性 sleep 到 next_run。原因是运行期可通过 API 更新 cron / 手动
|
||||||
|
调整 next_run;若按 wait_seconds 长时间沉睡,变更最长要等
|
||||||
|
wait_seconds 才生效(实测可达 60s),表现为「改了不生效」。
|
||||||
|
"""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
if not self.enabled or not self.next_run:
|
||||||
|
await asyncio.sleep(self.SLEEP_TICK)
|
||||||
|
self._calc_next()
|
||||||
|
continue
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
wait_seconds = (self.next_run - now).total_seconds()
|
||||||
|
if wait_seconds > 0:
|
||||||
|
# 短 tick 轮询,保证 next_run/cron 变更能及时被感知
|
||||||
|
await asyncio.sleep(min(wait_seconds, self.SLEEP_TICK))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 执行任务:先推进 next_run 再执行,避免任务耗时导致重复触发
|
||||||
|
self.last_run = datetime.now()
|
||||||
|
self._calc_next()
|
||||||
|
try:
|
||||||
|
logger.info("定时任务触发: %s (cron=%s)", self.task_id, self.cron)
|
||||||
|
await self.fn()
|
||||||
|
logger.info("定时任务完成: %s", self.task_id)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
# 单个任务失败不能终止循环,否则一次异常即永久停摆
|
||||||
|
logger.exception("定时任务失败: %s", self.task_id)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
# 循环体自身的意外异常(如 _calc_next)也不能终止调度
|
||||||
|
logger.exception("调度循环异常: %s", self.task_id)
|
||||||
|
await asyncio.sleep(self.SLEEP_TICK)
|
||||||
|
|
||||||
|
|
||||||
|
class DataQualityScheduler:
|
||||||
|
"""数据质量检查调度器(独立于采集任务)。"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._task: asyncio.Task | None = None
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
self._running = True
|
||||||
|
self._task = asyncio.create_task(self._run_loop())
|
||||||
|
logger.info("数据质量检查调度器已启动")
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
self._running = False
|
||||||
|
if self._task:
|
||||||
|
self._task.cancel()
|
||||||
|
|
||||||
|
async def _run_loop(self) -> None:
|
||||||
|
"""每小时执行一次数据质量检查。"""
|
||||||
|
while self._running:
|
||||||
|
try:
|
||||||
|
await self._run_checks()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("数据质量检查失败")
|
||||||
|
await asyncio.sleep(3600) # 每小时
|
||||||
|
|
||||||
|
async def _run_checks(self) -> None:
|
||||||
|
"""执行数据质量检查并写入 DataQualityCheck 表。"""
|
||||||
|
from src.db.base import AsyncSessionLocal
|
||||||
|
from src.db.models import DataQualityCheck, Match, MatchStats, Standing, League
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
# 检查1: 已完赛但无统计的比赛数
|
||||||
|
finished_no_stats = (
|
||||||
|
await db.execute(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(Match)
|
||||||
|
.outerjoin(MatchStats, Match.id == MatchStats.match_id)
|
||||||
|
.where(Match.match_status == "finished")
|
||||||
|
.where(MatchStats.id.is_(None))
|
||||||
|
)
|
||||||
|
).scalar() or 0
|
||||||
|
|
||||||
|
db.add(DataQualityCheck(
|
||||||
|
check_name="finished_without_stats",
|
||||||
|
entity_type="match",
|
||||||
|
actual_value=float(finished_no_stats),
|
||||||
|
passed=finished_no_stats == 0,
|
||||||
|
severity="warning" if finished_no_stats > 0 else "info",
|
||||||
|
detail={"message": f"{finished_no_stats} 场已完赛比赛缺少统计数据"},
|
||||||
|
))
|
||||||
|
|
||||||
|
# 检查2: 积分榜缺失的联赛数
|
||||||
|
leagues_without_standings = (
|
||||||
|
await db.execute(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(League)
|
||||||
|
.outerjoin(Standing, League.id == Standing.league_id)
|
||||||
|
.where(Standing.id.is_(None))
|
||||||
|
)
|
||||||
|
).scalar() or 0
|
||||||
|
|
||||||
|
db.add(DataQualityCheck(
|
||||||
|
check_name="league_without_standings",
|
||||||
|
entity_type="league",
|
||||||
|
actual_value=float(leagues_without_standings),
|
||||||
|
passed=leagues_without_standings == 0,
|
||||||
|
severity="warning" if leagues_without_standings > 0 else "info",
|
||||||
|
detail={"message": f"{leagues_without_standings} 个联赛缺少积分榜"},
|
||||||
|
))
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
logger.info("数据质量检查完成: stats=%d, standings=%d", finished_no_stats, leagues_without_standings)
|
||||||
|
|
||||||
|
|
||||||
|
# 全局单例
|
||||||
|
quality_scheduler = DataQualityScheduler()
|
||||||
|
|
||||||
|
|
||||||
|
class Scheduler:
|
||||||
|
"""全局定时任务调度器。"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._tasks: dict[str, ScheduledTask] = {}
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
def register(
|
||||||
|
self,
|
||||||
|
task_id: str,
|
||||||
|
cron: str,
|
||||||
|
fn: Callable[[], Coroutine],
|
||||||
|
enabled: bool = True,
|
||||||
|
) -> ScheduledTask:
|
||||||
|
"""注册(或更新)一个定时任务。
|
||||||
|
|
||||||
|
若调度器已 start,新任务会立即启动其运行循环 ——
|
||||||
|
否则运行期通过 API 新建的任务永远不会被执行(全量审查 C1 缺陷 2)。
|
||||||
|
"""
|
||||||
|
existing = self._tasks.get(task_id)
|
||||||
|
if existing is not None:
|
||||||
|
existing.update(cron=cron, enabled=enabled)
|
||||||
|
# 更新 cron 后重新校验:改坏了要立刻报错,而不是静默失活
|
||||||
|
existing._calc_next(raise_on_error=True)
|
||||||
|
existing.fn = fn
|
||||||
|
task = existing
|
||||||
|
else:
|
||||||
|
task = ScheduledTask(task_id, cron, fn, enabled)
|
||||||
|
self._tasks[task_id] = task
|
||||||
|
|
||||||
|
if self._running:
|
||||||
|
self._ensure_loop(task)
|
||||||
|
return task
|
||||||
|
|
||||||
|
def _ensure_loop(self, task: ScheduledTask) -> None:
|
||||||
|
"""为任务启动运行循环(幂等:已在运行则跳过)。"""
|
||||||
|
if task._task is None or task._task.done():
|
||||||
|
task._task = asyncio.create_task(task._run_loop())
|
||||||
|
|
||||||
|
def get(self, task_id: str) -> ScheduledTask | None:
|
||||||
|
return self._tasks.get(task_id)
|
||||||
|
|
||||||
|
def list_all(self) -> list[ScheduledTask]:
|
||||||
|
return list(self._tasks.values())
|
||||||
|
|
||||||
|
def remove(self, task_id: str) -> None:
|
||||||
|
task = self._tasks.pop(task_id, None)
|
||||||
|
if task is not None and task._task is not None:
|
||||||
|
task._task.cancel()
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
self._running = True
|
||||||
|
for task in self._tasks.values():
|
||||||
|
self._ensure_loop(task)
|
||||||
|
logger.info("定时调度器已启动, 共 %d 个任务", len(self._tasks))
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
self._running = False
|
||||||
|
for task in self._tasks.values():
|
||||||
|
if task._task:
|
||||||
|
task._task.cancel()
|
||||||
|
self._tasks.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# 全局单例
|
||||||
|
scheduler = Scheduler()
|
||||||
+412
-76
@@ -1,28 +1,33 @@
|
|||||||
"""Bzzoiro 数据源:抓取 + 入库。
|
"""Bzzoiro 数据源:抓取 + 入库(单一数据源)。
|
||||||
|
|
||||||
迁移自旧项目 app/data/sources/bzzoiro/,改成 async + 简化入库。
|
三条管线:
|
||||||
使用 Repository 模式进行数据访问,不直接控制事务。
|
1. events — 比赛日程/比分(/events/),含 source_event_id 血缘
|
||||||
|
2. standings— 联赛积分榜快照(/leagues/{id}/standings/)
|
||||||
|
3. stats — 已完赛比赛详细统计回填(/events/{id}/stats/)
|
||||||
|
|
||||||
|
使用 Repository 模式进行数据访问,不直接控制事务(由调用方 UnitOfWork 控制)。
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json as _json
|
|
||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from src.core.runtime_config import get_runtime_value
|
from src.core.runtime_config import get_runtime_value
|
||||||
from src.core.http_client import get_client
|
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.config import BZZOIRO_LEAGUE_IDS, LEAGUE_COUNTRIES, LEAGUE_NAMES, REQUEST_INTERVAL
|
||||||
|
from src.data.key_ring import _mask, get_key_ring
|
||||||
from src.data.normalize import normalize_bzzoiro
|
from src.data.normalize import normalize_bzzoiro
|
||||||
from src.data.team_names_zh import zh_name
|
from src.data.team_names_zh import zh_name
|
||||||
from src.data.sources import register
|
from src.data.sources import register
|
||||||
from src.db.models import League, Match, MatchStats, Team
|
from src.db.models import League, Match, MatchStats, Standing, Team, RawEvent, IngestFailure, DataLineage
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -36,6 +41,16 @@ def _to_date(value):
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _to_int_or_none(value) -> int | None:
|
||||||
|
"""宽松转 int(用于上游 ID 解析,失败返回 None 不抛错)。"""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(str(value).strip())
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _match_key(home_team_id: int, away_team_id: int, match_date) -> tuple[int, int, str]:
|
def _match_key(home_team_id: int, away_team_id: int, match_date) -> tuple[int, int, str]:
|
||||||
"""比赛去重键:(主队, 客队, 天级日期 ISO 字符串)。
|
"""比赛去重键:(主队, 客队, 天级日期 ISO 字符串)。
|
||||||
|
|
||||||
@@ -48,20 +63,25 @@ 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:
|
async def _fetch_json_async(path: str, params: dict | None = None, max_retries: int = 3) -> dict | list:
|
||||||
"""异步 HTTP(bzzoiro 使用 httpx,不再阻塞事件循环线程池)。"""
|
"""异步 HTTP(bzzoiro 使用 httpx,不再阻塞事件循环线程池)。
|
||||||
|
|
||||||
|
多 key 轮换:遇到 429 自动切换到下一个 key;全部 key 冷却时等待最早恢复。
|
||||||
|
"""
|
||||||
base = (await get_runtime_value("BZZOIRO_BASE")).rstrip("/")
|
base = (await get_runtime_value("BZZOIRO_BASE")).rstrip("/")
|
||||||
|
raw_keys = await get_runtime_value("BZZOIRO_KEY")
|
||||||
|
ring = get_key_ring(base, raw_keys)
|
||||||
|
|
||||||
url = f"{base}/{path.lstrip('/')}"
|
url = f"{base}/{path.lstrip('/')}"
|
||||||
key = await get_runtime_value("BZZOIRO_KEY")
|
key = ring.get()
|
||||||
if not key:
|
if not key:
|
||||||
raise RuntimeError("BZZOIRO_KEY 未设置")
|
raise RuntimeError("BZZOIRO_KEY 未设置")
|
||||||
|
|
||||||
headers = {
|
|
||||||
"Authorization": f"Token {key}",
|
|
||||||
"Accept": "application/json",
|
|
||||||
}
|
|
||||||
|
|
||||||
last_exc: Exception | None = None
|
last_exc: Exception | None = None
|
||||||
for attempt in range(max_retries):
|
for attempt in range(max_retries):
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Token {key}",
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
try:
|
try:
|
||||||
client = get_client()
|
client = get_client()
|
||||||
# 整请求兜底: httpx 无 total 超时,用 wait_for 防「滴水式」限速挂死
|
# 整请求兜底: httpx 无 total 超时,用 wait_for 防「滴水式」限速挂死
|
||||||
@@ -78,9 +98,22 @@ async def _fetch_json_async(path: str, params: dict | None = None, max_retries:
|
|||||||
last_exc = e
|
last_exc = e
|
||||||
status = getattr(getattr(e, "response", None), "status_code", None)
|
status = getattr(getattr(e, "response", None), "status_code", None)
|
||||||
if status == 429:
|
if status == 429:
|
||||||
delay = min(2 ** attempt, 16) + random.uniform(0, 1)
|
# 限流:标记当前 key 冷却,切换到下一个
|
||||||
logger.warning("bzzoiro 429, retry %d in %.1fs", attempt + 1, delay)
|
new_key = ring.report_rate_limited(key)
|
||||||
await asyncio.sleep(delay)
|
if new_key and new_key != key:
|
||||||
|
logger.info("bzzoiro 429 → 切换 key: %s → %s,立即重试", _mask(key), _mask(new_key))
|
||||||
|
key = new_key
|
||||||
|
continue # 立即重试,不等待
|
||||||
|
# 单 key 或全部冷却:等待最早恢复的 key
|
||||||
|
wait = ring.wait_if_all_blocked()
|
||||||
|
if wait > 0:
|
||||||
|
logger.warning("bzzoiro 全部 key 冷却,等待 %.1fs 后重试", wait)
|
||||||
|
await asyncio.sleep(min(wait, 30.0))
|
||||||
|
else:
|
||||||
|
delay = min(2 ** attempt, 16) + random.uniform(0, 1)
|
||||||
|
logger.warning("bzzoiro 429, retry %d in %.1fs", attempt + 1, delay)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
key = ring.get() or key
|
||||||
continue
|
continue
|
||||||
if 500 <= (status or 0) < 600:
|
if 500 <= (status or 0) < 600:
|
||||||
delay = min(2 ** attempt, 16) + random.uniform(0, 1)
|
delay = min(2 ** attempt, 16) + random.uniform(0, 1)
|
||||||
@@ -164,6 +197,7 @@ class BzzoiroSource:
|
|||||||
try:
|
try:
|
||||||
raw_events = await fetch_bzzoiro_events(code, status=status, date_from=date_from, date_to=date_to)
|
raw_events = await fetch_bzzoiro_events(code, status=status, date_from=date_from, date_to=date_to)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
# 单联赛抓取失败隔离:记录错误后继续其余联赛,不拖垮整批
|
||||||
logger.exception("bzzoiro fetch failed for %s", code)
|
logger.exception("bzzoiro fetch failed for %s", code)
|
||||||
league_r["errors"].append(f"fetch failed: {e}")
|
league_r["errors"].append(f"fetch failed: {e}")
|
||||||
result["leagues"][code] = league_r
|
result["leagues"][code] = league_r
|
||||||
@@ -180,7 +214,7 @@ class BzzoiroSource:
|
|||||||
# === 批量优化: 预加载球队和已有比赛到内存 ===
|
# === 批量优化: 预加载球队和已有比赛到内存 ===
|
||||||
team_name_to_id: dict[str, int] = {}
|
team_name_to_id: dict[str, int] = {}
|
||||||
existing_matches: dict[tuple[int, int, str], Match] = {} # 完整对象,避免重复查询
|
existing_matches: dict[tuple[int, int, str], Match] = {} # 完整对象,避免重复查询
|
||||||
# (NormalizedMatch, 原始 event) 成对保存:后续写 source_record_id 时
|
# (NormalizedMatch, 原始 event) 成对保存:后续写 source_event_id 时
|
||||||
# 必须用配对的那条 event,不能依赖外层循环变量残留值。
|
# 必须用配对的那条 event,不能依赖外层循环变量残留值。
|
||||||
normalized_matches: list[tuple] = []
|
normalized_matches: list[tuple] = []
|
||||||
|
|
||||||
@@ -208,7 +242,6 @@ class BzzoiroSource:
|
|||||||
# P1-2: 按需加载,只加载 raw_events 涉及日期范围的比赛(加 30 天缓冲)
|
# P1-2: 按需加载,只加载 raw_events 涉及日期范围的比赛(加 30 天缓冲)
|
||||||
# 避免加载联赛全部历史比赛到内存(多赛季采集时内存溢出)
|
# 避免加载联赛全部历史比赛到内存(多赛季采集时内存溢出)
|
||||||
if normalized_matches:
|
if normalized_matches:
|
||||||
from datetime import timedelta
|
|
||||||
# normalized_matches 存的是 (nm, raw) 元组,遍历需解包
|
# normalized_matches 存的是 (nm, raw) 元组,遍历需解包
|
||||||
dates = [nm.date for nm, _raw in normalized_matches if nm.date is not None]
|
dates = [nm.date for nm, _raw in normalized_matches if nm.date is not None]
|
||||||
if dates:
|
if dates:
|
||||||
@@ -262,38 +295,13 @@ class BzzoiroSource:
|
|||||||
home_ht_goals=nm.home_ht_goals,
|
home_ht_goals=nm.home_ht_goals,
|
||||||
away_ht_goals=nm.away_ht_goals,
|
away_ht_goals=nm.away_ht_goals,
|
||||||
match_stage=nm.match_stage,
|
match_stage=nm.match_stage,
|
||||||
|
source_event_id=_to_int_or_none(raw.get("id")),
|
||||||
)
|
)
|
||||||
db.add(m)
|
db.add(m)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
existing_matches[match_key] = m # 防止同批重复
|
existing_matches[match_key] = m # 防止同批重复
|
||||||
# 存在任一统计字段即可创建 MatchStats(不再强制要求 xG)
|
# 统计字段不在 /events/ 载荷中(单独由 stats 管线回填),
|
||||||
if any(getattr(nm, f) is not None for f in ['home_xg', 'away_xg', 'home_shots', 'away_shots', 'home_shots_on_target', 'away_shots_on_target', 'home_corners', 'away_corners', 'home_possession', 'home_yellow_cards', 'away_yellow_cards', 'home_red_cards', 'away_red_cards']):
|
# 此处不再创建 MatchStats。
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
# available_at 语义:统计「可被使用」的最早时间,至少不早于比赛结束
|
|
||||||
# 近似:开球 + 2 小时(实际完赛时间约为 +2 小时,非官方公布时间)
|
|
||||||
# 回测 cutoff 若贴着开球,不会把「完赛后才有的统计」误标为赛前可用
|
|
||||||
available_at = nm.date + timedelta(hours=2) if nm.date else now
|
|
||||||
stats = MatchStats(
|
|
||||||
match_id=m.id,
|
|
||||||
home_xg=nm.home_xg,
|
|
||||||
away_xg=nm.away_xg,
|
|
||||||
home_shots=nm.home_shots,
|
|
||||||
away_shots=nm.away_shots,
|
|
||||||
home_shots_on_target=nm.home_shots_on_target,
|
|
||||||
away_shots_on_target=nm.away_shots_on_target,
|
|
||||||
home_corners=nm.home_corners,
|
|
||||||
away_corners=nm.away_corners,
|
|
||||||
home_possession=nm.home_possession,
|
|
||||||
home_yellow_cards=nm.home_yellow_cards,
|
|
||||||
away_yellow_cards=nm.away_yellow_cards,
|
|
||||||
home_red_cards=nm.home_red_cards,
|
|
||||||
away_red_cards=nm.away_red_cards,
|
|
||||||
source="bzzoiro",
|
|
||||||
source_record_id=str(raw.get("id", "")),
|
|
||||||
retrieved_at=now,
|
|
||||||
available_at=available_at,
|
|
||||||
)
|
|
||||||
db.add(stats)
|
|
||||||
league_r["inserted"] += 1
|
league_r["inserted"] += 1
|
||||||
else:
|
else:
|
||||||
# 已有比赛: 直接从内存获取对象更新(无需再查询)
|
# 已有比赛: 直接从内存获取对象更新(无需再查询)
|
||||||
@@ -310,37 +318,11 @@ class BzzoiroSource:
|
|||||||
if existing_match.match_stage is None and nm.match_stage:
|
if existing_match.match_stage is None and nm.match_stage:
|
||||||
existing_match.match_stage = nm.match_stage
|
existing_match.match_stage = nm.match_stage
|
||||||
changed = True
|
changed = True
|
||||||
# 存在任一统计字段即可创建 MatchStats(不再强制要求 xG)
|
if existing_match.source_event_id is None:
|
||||||
if existing_match.stats is None and (
|
eid = _to_int_or_none(raw.get("id"))
|
||||||
nm.home_xg is not None or nm.away_xg is not None
|
if eid is not None:
|
||||||
or nm.home_shots is not None or nm.away_shots is not None
|
existing_match.source_event_id = eid
|
||||||
or nm.home_corners is not None or nm.away_corners is not None
|
changed = True
|
||||||
or nm.home_possession is not None
|
|
||||||
):
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
# available_at 语义:统计「可被使用」的最早时间,至少不早于比赛结束
|
|
||||||
# 近似:开球 + 2 小时(实际完赛时间约为 +2 小时,非官方公布时间)
|
|
||||||
available_at = nm.date + timedelta(hours=2) if nm.date else now
|
|
||||||
existing_match.stats = MatchStats(
|
|
||||||
match_id=existing_match.id,
|
|
||||||
source="bzzoiro",
|
|
||||||
source_record_id=str(raw.get("id", "")),
|
|
||||||
retrieved_at=now,
|
|
||||||
available_at=available_at,
|
|
||||||
)
|
|
||||||
db.add(existing_match.stats)
|
|
||||||
await db.flush()
|
|
||||||
if existing_match.stats is not None:
|
|
||||||
for fld in ("home_xg", "away_xg", "home_shots", "away_shots",
|
|
||||||
"home_shots_on_target", "away_shots_on_target",
|
|
||||||
"home_corners", "away_corners", "home_possession",
|
|
||||||
"home_yellow_cards", "away_yellow_cards",
|
|
||||||
"home_red_cards", "away_red_cards"):
|
|
||||||
if getattr(existing_match.stats, fld, None) is None:
|
|
||||||
v = getattr(nm, fld, None)
|
|
||||||
if v is not None:
|
|
||||||
setattr(existing_match.stats, fld, v)
|
|
||||||
changed = True
|
|
||||||
if changed:
|
if changed:
|
||||||
league_r["updated"] += 1
|
league_r["updated"] += 1
|
||||||
|
|
||||||
@@ -349,3 +331,357 @@ class BzzoiroSource:
|
|||||||
result["total_inserted"] += league_r["inserted"]
|
result["total_inserted"] += league_r["inserted"]
|
||||||
result["total_updated"] += league_r["updated"]
|
result["total_updated"] += league_r["updated"]
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 管线基础设施:RawEvent / IngestFailure / DataLineage
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
async def _write_raw_event(db, source_system: str, source_record_id: str, raw_payload: dict, batch_id: str | None = None) -> None:
|
||||||
|
"""写入 Bronze 层原始事件(幂等:同 source_record_id 跳过)。"""
|
||||||
|
from sqlalchemy import select as _select
|
||||||
|
stmt = _select(RawEvent).where(
|
||||||
|
RawEvent.source_system == source_system,
|
||||||
|
RawEvent.source_record_id == source_record_id,
|
||||||
|
)
|
||||||
|
existing = (await db.execute(stmt)).scalar_one_or_none()
|
||||||
|
if existing is None:
|
||||||
|
db.add(RawEvent(
|
||||||
|
source_system=source_system,
|
||||||
|
source_record_id=source_record_id,
|
||||||
|
raw_payload=raw_payload,
|
||||||
|
ingest_batch_id=batch_id,
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
async def _write_ingest_failure(db, source_system: str, entity_type: str, source_record_id: str | None, error_type: str, error_detail: str | None, raw_payload: dict | None = None) -> None:
|
||||||
|
"""写入采集失败死信。"""
|
||||||
|
db.add(IngestFailure(
|
||||||
|
source_system=source_system,
|
||||||
|
entity_type=entity_type,
|
||||||
|
source_record_id=source_record_id,
|
||||||
|
error_type=error_type,
|
||||||
|
error_detail=error_detail,
|
||||||
|
raw_payload=raw_payload,
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
async def _write_lineage(db, source_system: str, source_record_id: str, target_table: str, target_id: int | None, transform_name: str, transform_detail: dict | None = None, batch_id: str | None = None) -> None:
|
||||||
|
"""写入 ETL 血缘追踪。"""
|
||||||
|
db.add(DataLineage(
|
||||||
|
source_system=source_system,
|
||||||
|
source_record_id=source_record_id,
|
||||||
|
target_table=target_table,
|
||||||
|
target_id=target_id,
|
||||||
|
transform_name=transform_name,
|
||||||
|
transform_detail=transform_detail,
|
||||||
|
batch_id=batch_id,
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 积分榜管线:/leagues/{id}/standings/ → standings 表
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
async def fetch_bzzoiro_standings(league_code: str, season: str | None = None) -> dict:
|
||||||
|
"""抓取联赛积分榜(纯抓取,不入库)。season 为 None 时取当前赛季。"""
|
||||||
|
league_id = BZZOIRO_LEAGUE_IDS.get(league_code)
|
||||||
|
if league_id is None:
|
||||||
|
raise ValueError(f"未知联赛代码: {league_code}")
|
||||||
|
params: dict = {}
|
||||||
|
if season:
|
||||||
|
params["season"] = season
|
||||||
|
return await _fetch_json_async(f"/leagues/{league_id}/standings/", params)
|
||||||
|
|
||||||
|
|
||||||
|
def _season_label_from_dates(start_date, end_date) -> str:
|
||||||
|
"""从赛季起止日期推导赛季标签(与 derive_season_label 语义一致)。"""
|
||||||
|
try:
|
||||||
|
if isinstance(start_date, str):
|
||||||
|
start = datetime.fromisoformat(start_date[:10])
|
||||||
|
else:
|
||||||
|
start = start_date
|
||||||
|
y = start.year
|
||||||
|
return f"{y}-{y + 1}" if start.month >= 8 else f"{y - 1}-{y}"
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return "?"
|
||||||
|
|
||||||
|
|
||||||
|
async def ingest_bzzoiro_standings(db, *, leagues: Iterable[str], season: str | None = None) -> dict:
|
||||||
|
"""采集积分榜 → upsert standings 表。
|
||||||
|
|
||||||
|
season 为 None 时采集当前赛季(bzzoiro 默认返回 is_current 赛季)。
|
||||||
|
球队名与 events 管线使用同一 normalize 规则,保证 Team 匹配。
|
||||||
|
"""
|
||||||
|
from src.data.team_names import normalize as normalize_name
|
||||||
|
|
||||||
|
result: dict = {"leagues": {}, "total_upserted": 0, "errors": []}
|
||||||
|
for code in leagues:
|
||||||
|
league_r: dict = {"upserted": 0, "teams_created": 0, "rows": 0}
|
||||||
|
try:
|
||||||
|
payload = await fetch_bzzoiro_standings(code, season=season)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("bzzoiro standings fetch failed for %s", code)
|
||||||
|
league_r["errors"].append(str(e))
|
||||||
|
result["leagues"][code] = league_r
|
||||||
|
result["errors"].append(f"{code}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
rows = payload.get("standings") or []
|
||||||
|
if not rows:
|
||||||
|
result["leagues"][code] = {"error": "无积分榜数据(赛季未开始或未提供)"}
|
||||||
|
result["errors"].append(f"{code}: 无积分榜数据")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 联赛(get-or-create)
|
||||||
|
stmt = select(League).where(League.code == code)
|
||||||
|
league = (await db.execute(stmt)).scalar_one_or_none()
|
||||||
|
if league is None:
|
||||||
|
league = League(code=code, name=LEAGUE_NAMES.get(code, code), country=LEAGUE_COUNTRIES.get(code))
|
||||||
|
db.add(league)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# 赛季标签:优先用返回的 season 对象推导
|
||||||
|
season_obj = payload.get("season") or {}
|
||||||
|
season_label = _season_label_from_dates(
|
||||||
|
season_obj.get("start_date"), season_obj.get("end_date")
|
||||||
|
)
|
||||||
|
if season_label == "?":
|
||||||
|
season_label = season or ""
|
||||||
|
|
||||||
|
# 批量预载球队(与 events 管线使用同一 normalize 规则,保证 Team 匹配)
|
||||||
|
names = {normalize_name(str(r.get("team_name", ""))) for r in rows}
|
||||||
|
names.discard("")
|
||||||
|
team_map: dict[str, Team] = {}
|
||||||
|
if names:
|
||||||
|
stmt = select(Team).where(Team.name.in_(names))
|
||||||
|
for t in (await db.execute(stmt)).scalars():
|
||||||
|
team_map[t.name] = t
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
for r in rows:
|
||||||
|
team_name = normalize_name(str(r.get("team_name", "")))
|
||||||
|
if not team_name:
|
||||||
|
continue
|
||||||
|
team = team_map.get(team_name)
|
||||||
|
if team is None:
|
||||||
|
team = Team(name=team_name, name_zh=zh_name(team_name))
|
||||||
|
db.add(team)
|
||||||
|
await db.flush()
|
||||||
|
team_map[team_name] = team
|
||||||
|
league_r["teams_created"] += 1
|
||||||
|
|
||||||
|
zone = r.get("zone") or {}
|
||||||
|
values = dict(
|
||||||
|
position=_to_int_or_none(r.get("position")) or 0,
|
||||||
|
played=_to_int_or_none(r.get("played")) or 0,
|
||||||
|
won=_to_int_or_none(r.get("won")) or 0,
|
||||||
|
drawn=_to_int_or_none(r.get("drawn")) or 0,
|
||||||
|
lost=_to_int_or_none(r.get("lost")) or 0,
|
||||||
|
goals_for=_to_int_or_none(r.get("gf")) or 0,
|
||||||
|
goals_against=_to_int_or_none(r.get("ga")) or 0,
|
||||||
|
goal_diff=_to_int_or_none(r.get("gd")) or 0,
|
||||||
|
points=_to_int_or_none(r.get("pts")) or 0,
|
||||||
|
xg_for=_to_float_or_none(r.get("xgf")),
|
||||||
|
xg_against=_to_float_or_none(r.get("xga")),
|
||||||
|
form=r.get("form") or None,
|
||||||
|
zone=zone.get("label") or zone.get("key") or None,
|
||||||
|
updated_at=now,
|
||||||
|
retrieved_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 同一联赛同一赛季只保留最新快照:按 (league, season, team) upsert
|
||||||
|
stmt = select(Standing).where(
|
||||||
|
Standing.league_id == league.id,
|
||||||
|
Standing.season == season_label,
|
||||||
|
Standing.team_id == team.id,
|
||||||
|
)
|
||||||
|
standing = (await db.execute(stmt)).scalar_one_or_none()
|
||||||
|
if standing is None:
|
||||||
|
standing = Standing(
|
||||||
|
league_id=league.id, season=season_label, team_id=team.id, **values
|
||||||
|
)
|
||||||
|
db.add(standing)
|
||||||
|
else:
|
||||||
|
for k, v in values.items():
|
||||||
|
setattr(standing, k, v)
|
||||||
|
league_r["upserted"] += 1
|
||||||
|
|
||||||
|
league_r["rows"] = len(rows)
|
||||||
|
result["leagues"][code] = league_r
|
||||||
|
result["total_upserted"] += league_r["upserted"]
|
||||||
|
logger.info(
|
||||||
|
"bzzoiro standings 采集完成: %s 赛季 %s, upsert %d/%d",
|
||||||
|
code, season_label, league_r["upserted"], league_r["rows"],
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 统计回填管线:/events/{id}/stats/ → match_stats 表
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# bzzoiro stats 字段 → MatchStats 字段映射(stats.home / stats.away 下)
|
||||||
|
_STATS_FIELD_MAP = {
|
||||||
|
"xg": ("home_xg", "away_xg"), # 回退 expected_goals
|
||||||
|
"ball_possession": ("home_possession", None), # 只取主队值,客队=100-home
|
||||||
|
"total_shots": ("home_shots", "away_shots"),
|
||||||
|
"shots_on_target": ("home_shots_on_target", "away_shots_on_target"),
|
||||||
|
"corner_kicks": ("home_corners", "away_corners"),
|
||||||
|
"yellow_cards": ("home_yellow_cards", "away_yellow_cards"),
|
||||||
|
"red_cards": ("home_red_cards", "away_red_cards"),
|
||||||
|
"big_chances": ("home_big_chances", "away_big_chances"),
|
||||||
|
"fouls": ("home_fouls", "away_fouls"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _pick(d: dict, *keys):
|
||||||
|
"""按优先级取第一个非空字段值。"""
|
||||||
|
for k in keys:
|
||||||
|
v = d.get(k)
|
||||||
|
if v is not None:
|
||||||
|
return v
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _stats_from_payload(payload: dict) -> dict:
|
||||||
|
"""把 /events/{id}/stats/ 响应映射成 MatchStats 字段 dict。
|
||||||
|
|
||||||
|
响应结构: {"event_id": ..., "stats": {"home": {...}, "away": {...}}}
|
||||||
|
"""
|
||||||
|
stats = (payload or {}).get("stats") or {}
|
||||||
|
home = stats.get("home") or {}
|
||||||
|
away = stats.get("away") or {}
|
||||||
|
out: dict = {}
|
||||||
|
|
||||||
|
xg_h = _pick(home, "xg", "expected_goals")
|
||||||
|
xg_a = _pick(away, "xg", "expected_goals")
|
||||||
|
if xg_h is not None:
|
||||||
|
out["home_xg"] = _to_float_or_none(xg_h)
|
||||||
|
if xg_a is not None:
|
||||||
|
out["away_xg"] = _to_float_or_none(xg_a)
|
||||||
|
|
||||||
|
poss = home.get("ball_possession")
|
||||||
|
if poss is not None:
|
||||||
|
p = _to_float_or_none(poss)
|
||||||
|
if p is not None:
|
||||||
|
out["home_possession"] = p
|
||||||
|
|
||||||
|
for src, (h_fld, a_fld) in _STATS_FIELD_MAP.items():
|
||||||
|
if src in ("xg", "ball_possession"):
|
||||||
|
continue # 已处理
|
||||||
|
hv = home.get(src)
|
||||||
|
av = away.get(src)
|
||||||
|
if hv is not None and h_fld:
|
||||||
|
out[h_fld] = _to_int_or_none(hv)
|
||||||
|
if av is not None and a_fld:
|
||||||
|
out[a_fld] = _to_int_or_none(av)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _to_float_or_none(value) -> float | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(str(value).strip())
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def ingest_bzzoiro_event_stats(
|
||||||
|
db,
|
||||||
|
*,
|
||||||
|
leagues: Iterable[str],
|
||||||
|
limit: int = 100,
|
||||||
|
only_missing: bool = True,
|
||||||
|
) -> dict:
|
||||||
|
"""回填已完赛比赛的详细统计(逐场调 /events/{id}/stats/)。
|
||||||
|
|
||||||
|
筛选条件: match_status=finished 且 source_event_id 非空。
|
||||||
|
only_missing=True 时跳过已有统计的比赛(增量);False 则全量刷新。
|
||||||
|
limit 控制单次最多处理的比赛数(上游限速 1.2s/请求,大批量需分次触发)。
|
||||||
|
"""
|
||||||
|
result: dict = {"fetched": 0, "created": 0, "updated": 0, "skipped": 0, "errors": []}
|
||||||
|
|
||||||
|
league_ids = [BZZOIRO_LEAGUE_IDS[c] for c in leagues if c in BZZOIRO_LEAGUE_IDS]
|
||||||
|
if not league_ids:
|
||||||
|
result["errors"].append("无有效联赛代码")
|
||||||
|
return result
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(Match)
|
||||||
|
.options(selectinload(Match.stats))
|
||||||
|
.where(Match.match_status == "finished")
|
||||||
|
.where(Match.source_event_id.is_not(None))
|
||||||
|
.where(Match.league_id.in_(league_ids))
|
||||||
|
.order_by(Match.match_date.desc())
|
||||||
|
.limit(limit * 3 if only_missing else limit)
|
||||||
|
)
|
||||||
|
matches = (await db.execute(stmt)).scalars().all()
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
processed = 0
|
||||||
|
for m in matches:
|
||||||
|
if processed >= limit:
|
||||||
|
break
|
||||||
|
if only_missing and m.stats is not None and m.stats.home_shots is not None:
|
||||||
|
result["skipped"] += 1
|
||||||
|
continue
|
||||||
|
processed += 1
|
||||||
|
try:
|
||||||
|
payload = await _fetch_json_async(f"/events/{m.source_event_id}/stats/")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("stats fetch failed match=%s event=%s: %s", m.id, m.source_event_id, e)
|
||||||
|
result["errors"].append(f"match {m.id}: {e}")
|
||||||
|
await asyncio.sleep(REQUEST_INTERVAL)
|
||||||
|
continue
|
||||||
|
|
||||||
|
result["fetched"] += 1
|
||||||
|
fields = _stats_from_payload(payload)
|
||||||
|
if not fields:
|
||||||
|
result["skipped"] += 1
|
||||||
|
await asyncio.sleep(REQUEST_INTERVAL)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if m.stats is None:
|
||||||
|
available_at = m.match_date + timedelta(hours=2) if m.match_date else now
|
||||||
|
m.stats = MatchStats(
|
||||||
|
match_id=m.id,
|
||||||
|
source="bzzoiro",
|
||||||
|
source_record_id=str(m.source_event_id),
|
||||||
|
retrieved_at=now,
|
||||||
|
available_at=available_at,
|
||||||
|
)
|
||||||
|
db.add(m.stats)
|
||||||
|
result["created"] += 1
|
||||||
|
else:
|
||||||
|
result["updated"] += 1
|
||||||
|
if m.stats.source is None:
|
||||||
|
m.stats.source = "bzzoiro"
|
||||||
|
m.stats.source_record_id = str(m.source_event_id)
|
||||||
|
if m.stats.retrieved_at is None:
|
||||||
|
m.stats.retrieved_at = now
|
||||||
|
if m.stats.available_at is None and m.match_date:
|
||||||
|
m.stats.available_at = m.match_date + timedelta(hours=2)
|
||||||
|
|
||||||
|
for fld, v in fields.items():
|
||||||
|
if hasattr(m.stats, fld):
|
||||||
|
setattr(m.stats, fld, v)
|
||||||
|
|
||||||
|
# 管线基础设施:写入 RawEvent + DataLineage
|
||||||
|
batch_id = f"bzzoiro-stats-{m.source_event_id}-{now.strftime('%Y%m%d%H%M%S')}"
|
||||||
|
try:
|
||||||
|
await _write_raw_event(db, "bzzoiro", str(m.source_event_id), payload, batch_id)
|
||||||
|
await _write_lineage(db, "bzzoiro", str(m.source_event_id), "match_stats", m.stats.id if m.stats else None, "stats_backfill", {"match_id": m.id}, batch_id)
|
||||||
|
except Exception:
|
||||||
|
pass # 基础设施写入失败不影响主流程
|
||||||
|
|
||||||
|
await asyncio.sleep(REQUEST_INTERVAL)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"bzzoiro stats 回填完成: 抓取 %d, 新建 %d, 更新 %d, 跳过 %d, 错误 %d",
|
||||||
|
result["fetched"], result["created"], result["updated"],
|
||||||
|
result["skipped"], len(result["errors"]),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|||||||
+1
-10
@@ -1,4 +1,4 @@
|
|||||||
"""数据源配置常量(联赛映射)。"""
|
"""数据源配置常量(联赛映射)。数据源统一为 bzzoiro(单一数据源)。"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
# fdco 风格代码 → bzzoiro league_id
|
# fdco 风格代码 → bzzoiro league_id
|
||||||
@@ -12,15 +12,6 @@ BZZOIRO_LEAGUE_IDS: dict[str, int] = {
|
|||||||
"EL": 8, # Europa League
|
"EL": 8, # Europa League
|
||||||
}
|
}
|
||||||
|
|
||||||
# fdco 代码 → understat 联赛代码
|
|
||||||
FDCO_TO_UNDERSTAT: dict[str, str] = {
|
|
||||||
"E0": "EPL",
|
|
||||||
"SP1": "La_liga",
|
|
||||||
"D1": "Bundesliga",
|
|
||||||
"I1": "Serie_A",
|
|
||||||
"F1": "Ligue_1",
|
|
||||||
}
|
|
||||||
|
|
||||||
# fdco 代码 → 显示名
|
# fdco 代码 → 显示名
|
||||||
LEAGUE_NAMES: dict[str, str] = {
|
LEAGUE_NAMES: dict[str, str] = {
|
||||||
"E0": "Premier League",
|
"E0": "Premier League",
|
||||||
|
|||||||
@@ -1,347 +0,0 @@
|
|||||||
"""伤停数据采集器(api-football / api-sports.io)。
|
|
||||||
|
|
||||||
采集伤停数据并入库(injuries 表),供 injuries agent 使用。
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import random
|
|
||||||
import tempfile
|
|
||||||
import time
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from src.core.runtime_config import get_runtime_value
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class InjuryQueryResult:
|
|
||||||
"""伤停查询结果(区分「查询成功但为空」与「查询失败/源未配置」)。"""
|
|
||||||
|
|
||||||
records: list["Injury"]
|
|
||||||
query_status: str # "success" | "source_not_configured" | "query_error"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def has_data(self) -> bool:
|
|
||||||
"""成功查询(即使结果为空)视为有明确名单,has_data=True。"""
|
|
||||||
return self.query_status == "success"
|
|
||||||
from src.core.http_client import get_client
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
API_BASE = "https://v3.football.api-sports.io"
|
|
||||||
DEFAULT_HOST = "v3.football.api-sports.io"
|
|
||||||
|
|
||||||
# 缓存目录:系统临时目录
|
|
||||||
_CACHE_DIR = Path(tempfile.gettempdir()) / "profeto_injuries"
|
|
||||||
|
|
||||||
# Fix 5: 缓存 TTL 从 7 天改为 6 小时,同日再采不会命中旧数据
|
|
||||||
_CACHE_TTL_HOURS = 6
|
|
||||||
|
|
||||||
|
|
||||||
async def fetch_injuries(*, date: str | None = None, fixture_id: int | None = None, league_id: int | None = None) -> list[dict]:
|
|
||||||
"""采集伤停数据。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
date: 日期 (YYYY-MM-DD),返当天全部伤停
|
|
||||||
fixture_id: 指定比赛 ID
|
|
||||||
league_id: 指定联赛 ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
伤停记录列表
|
|
||||||
"""
|
|
||||||
api_key = await get_runtime_value("API_FOOTBALL_KEY")
|
|
||||||
if not api_key:
|
|
||||||
raise RuntimeError("API_FOOTBALL_KEY 未设置")
|
|
||||||
|
|
||||||
cache_dir = _CACHE_DIR
|
|
||||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# Fix 5: 缓存命中 (6 小时内有效)
|
|
||||||
cache_key = f"injuries_{date}_{fixture_id}_{league_id}.json"
|
|
||||||
cache_file = cache_dir / cache_key
|
|
||||||
if cache_file.exists():
|
|
||||||
age_hours = (time.time() - cache_file.stat().st_mtime) / 3600
|
|
||||||
if age_hours < _CACHE_TTL_HOURS:
|
|
||||||
logger.debug("injuries cache hit: %s (%.1fh old)", cache_key, age_hours)
|
|
||||||
with open(cache_file, encoding="utf-8") as f:
|
|
||||||
return json.load(f)
|
|
||||||
else:
|
|
||||||
logger.debug("injuries cache expired: %s (%.1fh old)", cache_key, age_hours)
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
"x-apisports-key": api_key,
|
|
||||||
"x-rapidapi-host": DEFAULT_HOST,
|
|
||||||
}
|
|
||||||
params: dict[str, Any] = {}
|
|
||||||
if date:
|
|
||||||
params["date"] = date
|
|
||||||
if fixture_id:
|
|
||||||
params["fixture"] = fixture_id
|
|
||||||
if league_id:
|
|
||||||
params["league"] = league_id
|
|
||||||
|
|
||||||
url = f"{API_BASE}/injuries"
|
|
||||||
|
|
||||||
# 重试
|
|
||||||
last_exc: Exception | None = None
|
|
||||||
for attempt in range(3):
|
|
||||||
try:
|
|
||||||
client = get_client()
|
|
||||||
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:
|
|
||||||
last_exc = e
|
|
||||||
if attempt == 2:
|
|
||||||
raise
|
|
||||||
delay = min(2 ** attempt, 8) + random.uniform(0, 1)
|
|
||||||
logger.warning("injuries fetch failed, retry %d in %.1fs: %s", attempt + 1, delay, e)
|
|
||||||
await asyncio.sleep(delay)
|
|
||||||
else:
|
|
||||||
raise RuntimeError(f"injuries fetch failed: {last_exc}")
|
|
||||||
|
|
||||||
data = resp.json()
|
|
||||||
injuries = data.get("response", [])
|
|
||||||
|
|
||||||
# 写缓存
|
|
||||||
with open(cache_file, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(injuries, ensure_ascii=False, default=str, fp=f)
|
|
||||||
|
|
||||||
return injuries
|
|
||||||
|
|
||||||
|
|
||||||
async def ingest_injuries(db, *, date: str | None = None) -> dict:
|
|
||||||
"""采集伤停数据并入库(injuries 表)。
|
|
||||||
|
|
||||||
注意: 本方法不控制事务(commit/rollback),由调用方通过 UnitOfWork 控制。
|
|
||||||
|
|
||||||
Fix 1: 使用 SAVEPOINT(begin_nested)避免整批回滚丢数据。
|
|
||||||
Fix 2: 正确解析并写入 return_date。
|
|
||||||
Fix 3: retrieved_at 比较统一用 timezone-aware datetime。
|
|
||||||
"""
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.exc import IntegrityError
|
|
||||||
|
|
||||||
from src.data.team_names import normalize as normalize_name
|
|
||||||
from src.db.models import Injury, Team
|
|
||||||
|
|
||||||
result = {"count": 0, "inserted": 0, "errors": []}
|
|
||||||
|
|
||||||
try:
|
|
||||||
raw_injuries = await fetch_injuries(date=date)
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception("injuries fetch failed")
|
|
||||||
result["errors"].append(f"fetch failed: {e}")
|
|
||||||
return result
|
|
||||||
|
|
||||||
result["count"] = len(raw_injuries)
|
|
||||||
|
|
||||||
# 预加载所有球队(用于按名匹配)
|
|
||||||
teams = (await db.execute(select(Team))).scalars().all()
|
|
||||||
team_by_name = {t.name: t.id for t in teams}
|
|
||||||
|
|
||||||
# 收集所有待插入记录(解析 + 校验)
|
|
||||||
pending_records: list[dict] = []
|
|
||||||
for raw in raw_injuries:
|
|
||||||
try:
|
|
||||||
player = raw.get("player", {}) or {}
|
|
||||||
team = raw.get("team", {}) or {}
|
|
||||||
fixture = raw.get("fixture", {}) or {}
|
|
||||||
|
|
||||||
player_name = player.get("name", "")
|
|
||||||
team_name = normalize_name(team.get("name", ""))
|
|
||||||
team_id = team_by_name.get(team_name)
|
|
||||||
|
|
||||||
# Fix 2: 解析日期(injury_date + return_date)
|
|
||||||
fixture_date = fixture.get("date")
|
|
||||||
injury_date = None
|
|
||||||
if fixture_date:
|
|
||||||
try:
|
|
||||||
dt = datetime.fromisoformat(fixture_date.replace("Z", "+00:00"))
|
|
||||||
injury_date = dt.date()
|
|
||||||
except (ValueError, AttributeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 解析 return_date(如果数据源提供)
|
|
||||||
return_date = None
|
|
||||||
return_date_raw = player.get("return_date") or player.get("returnDate")
|
|
||||||
if return_date_raw:
|
|
||||||
try:
|
|
||||||
dt = datetime.fromisoformat(str(return_date_raw).replace("Z", "+00:00"))
|
|
||||||
return_date = dt.date()
|
|
||||||
except (ValueError, AttributeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 强制 int 转换,API 可能返回字符串
|
|
||||||
player_id = player.get("id")
|
|
||||||
try:
|
|
||||||
player_id = int(player_id) if player_id is not None else None
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
player_id = None
|
|
||||||
fixture_id = fixture.get("id")
|
|
||||||
try:
|
|
||||||
fixture_id = int(fixture_id) if fixture_id is not None else None
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
fixture_id = None
|
|
||||||
|
|
||||||
pending_records.append({
|
|
||||||
"player_id": player_id,
|
|
||||||
"player_name": player_name,
|
|
||||||
"team_id": team_id,
|
|
||||||
"fixture_id": fixture_id,
|
|
||||||
"league_id": (raw.get("league") or {}).get("id"),
|
|
||||||
"injury_type": player.get("type"),
|
|
||||||
"reason": player.get("reason"),
|
|
||||||
"injury_date": injury_date,
|
|
||||||
"return_date": return_date,
|
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
result["errors"].append(f"parse error: {e}")
|
|
||||||
|
|
||||||
# 批量查询已存在的记录(1 次 DB 往返)
|
|
||||||
existing_keys: set[tuple] = set()
|
|
||||||
if pending_records:
|
|
||||||
conditions = []
|
|
||||||
for rec in pending_records:
|
|
||||||
conditions.append(
|
|
||||||
(Injury.player_id == rec["player_id"])
|
|
||||||
& (Injury.fixture_id == rec["fixture_id"])
|
|
||||||
& (Injury.injury_type == rec["injury_type"])
|
|
||||||
)
|
|
||||||
if conditions:
|
|
||||||
from sqlalchemy import or_
|
|
||||||
stmt = select(Injury.player_id, Injury.fixture_id, Injury.injury_type).where(or_(*conditions))
|
|
||||||
rows = (await db.execute(stmt)).all()
|
|
||||||
existing_keys = {(r[0], r[1], r[2]) for r in rows}
|
|
||||||
|
|
||||||
# Fix 1: 使用 begin_nested(SAVEPOINT)隔离每批 flush
|
|
||||||
# IntegrityError 时只回滚到 savepoint,不影响其它已成功批次
|
|
||||||
BATCH_SIZE = 50
|
|
||||||
batch: list[Injury] = []
|
|
||||||
|
|
||||||
async def _flush_batch():
|
|
||||||
"""使用 savepoint flush 一批记录;失败只回滚本批。返回实际写入条数。"""
|
|
||||||
if not batch:
|
|
||||||
return 0
|
|
||||||
count = len(batch)
|
|
||||||
async with db.begin_nested():
|
|
||||||
for obj in batch:
|
|
||||||
db.add(obj)
|
|
||||||
await db.flush()
|
|
||||||
batch.clear()
|
|
||||||
return count
|
|
||||||
|
|
||||||
for i, rec in enumerate(pending_records):
|
|
||||||
key = (rec["player_id"], rec["fixture_id"], rec["injury_type"])
|
|
||||||
if key in existing_keys:
|
|
||||||
continue
|
|
||||||
|
|
||||||
batch.append(Injury(**rec))
|
|
||||||
|
|
||||||
# 每 BATCH_SIZE 条 flush 一次
|
|
||||||
if len(batch) >= BATCH_SIZE:
|
|
||||||
try:
|
|
||||||
result["inserted"] += await _flush_batch()
|
|
||||||
except IntegrityError:
|
|
||||||
logger.warning(
|
|
||||||
"injuries batch IntegrityError at record %d, "
|
|
||||||
"rolled back to savepoint, continuing",
|
|
||||||
i + 1,
|
|
||||||
)
|
|
||||||
# begin_nested 已回滚到 savepoint,清空 batch 继续
|
|
||||||
batch.clear()
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 最终 flush(剩余不足一批的记录)
|
|
||||||
try:
|
|
||||||
result["inserted"] += await _flush_batch()
|
|
||||||
except IntegrityError:
|
|
||||||
logger.warning(
|
|
||||||
"injuries final flush IntegrityError, "
|
|
||||||
"rolled back to savepoint, some records may be lost",
|
|
||||||
)
|
|
||||||
batch.clear()
|
|
||||||
|
|
||||||
# 注意: 不在此处 commit,由调用方 UnitOfWork 控制事务
|
|
||||||
logger.info("injuries: fetched %d, inserted %d for %s", result["count"], result["inserted"], date)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
async def get_injuries_for_match(db, team_id: int, match_date, as_of=None) -> InjuryQueryResult:
|
|
||||||
"""查询某场比赛前某队的伤停名单(比赛日仍缺阵的)。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
db: 数据库 session
|
|
||||||
team_id: 球队 ID
|
|
||||||
match_date: 比赛日期
|
|
||||||
as_of: 数据截止时间(用于回测防泄漏)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
InjuryQueryResult:包含查询记录与状态
|
|
||||||
- query_status="success": 查询成功(即使结果也为空)
|
|
||||||
- query_status="source_not_configured": API_FOOTBALL_KEY 未配置
|
|
||||||
- query_status="query_error": 查询异常
|
|
||||||
- query_status="no_local_data": Key 已配置,但该队 injuries 表无任何历史记录
|
|
||||||
|
|
||||||
语义区分:
|
|
||||||
- success + 空结果 → has_data=True(明确知道「无人伤停」)
|
|
||||||
- no_local_data → has_data=False(本地尚未采集,需先 ingest)
|
|
||||||
- source_not_configured / query_error → has_data=False(无法判断)
|
|
||||||
"""
|
|
||||||
from sqlalchemy import select, func
|
|
||||||
from src.db.models import Injury
|
|
||||||
|
|
||||||
# 检查 API 是否配置(只读配置,不发网络)
|
|
||||||
api_key = await get_runtime_value("API_FOOTBALL_KEY")
|
|
||||||
if not api_key:
|
|
||||||
logger.debug("API_FOOTBALL_KEY 未配置,跳过伤停查询 team=%s", team_id)
|
|
||||||
return InjuryQueryResult(records=[], query_status="source_not_configured")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Fix 3: 统一用 timezone-aware datetime 比较,禁止 date() 截断
|
|
||||||
if hasattr(match_date, "date") and callable(match_date.date):
|
|
||||||
match_date = match_date.date()
|
|
||||||
|
|
||||||
stmt = (
|
|
||||||
select(Injury)
|
|
||||||
.where(Injury.team_id == team_id)
|
|
||||||
.where(Injury.injury_date <= match_date)
|
|
||||||
.where(
|
|
||||||
(Injury.return_date.is_(None)) | (Injury.return_date >= match_date)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if as_of is not None:
|
|
||||||
# Fix 3: 统一用 date 比较,避免 timestamptz vs date 的时区问题
|
|
||||||
if hasattr(as_of, "date") and callable(as_of.date):
|
|
||||||
as_of = as_of.date()
|
|
||||||
stmt = stmt.where(func.date(Injury.retrieved_at) <= as_of)
|
|
||||||
|
|
||||||
result = await db.execute(stmt)
|
|
||||||
records = list(result.scalars().all())
|
|
||||||
|
|
||||||
# 判定「无本地数据」:该队从未有伤停记录
|
|
||||||
# 规则:该 team_id 在 injuries 表中 count==0
|
|
||||||
if not records:
|
|
||||||
count_stmt = select(func.count()).where(Injury.team_id == team_id)
|
|
||||||
team_count = (await db.execute(count_stmt)).scalar_one() or 0
|
|
||||||
if team_count == 0:
|
|
||||||
logger.debug("API Key 已配置但本地无伤停数据 team=%s,标记 no_local_data", team_id)
|
|
||||||
return InjuryQueryResult(records=[], query_status="no_local_data")
|
|
||||||
|
|
||||||
return InjuryQueryResult(records=records, query_status="success")
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception("伤停查询异常 team=%s: %s", team_id, e)
|
|
||||||
return InjuryQueryResult(records=[], query_status="query_error")
|
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""API Key 轮换环:多 key 自动切换,遇到限流(429)自动跳过已冷却 key。
|
||||||
|
|
||||||
|
设计:
|
||||||
|
- 进程内纯内存状态(限速是短时状态,无需持久化)
|
||||||
|
- 单 key 场景零开销:直接透传
|
||||||
|
- 多 key 场景:429 时把当前 key 标记冷却(默认 60s),轮转到下一个可用 key
|
||||||
|
- 全部 key 都在冷却时:使用最早冷却的那个 key 并等待(退化到单 key 重试)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_COOLDOWN = 60.0 # 单个 key 被限流后的冷却时间(秒)
|
||||||
|
|
||||||
|
|
||||||
|
class KeyRing:
|
||||||
|
"""多 key 轮换环。在 async 单线程事件循环下无需加锁。"""
|
||||||
|
|
||||||
|
def __init__(self, keys: list[str], cooldown_seconds: float = DEFAULT_COOLDOWN) -> None:
|
||||||
|
self._keys: list[str] = [k.strip() for k in keys if k and k.strip()]
|
||||||
|
self._cooldown = cooldown_seconds
|
||||||
|
# key → 冷却过期时间戳(时刻);不在表中表示可用
|
||||||
|
self._blocked_until: dict[str, float] = {}
|
||||||
|
self._index = 0 # 当前轮转位置
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_multiple(self) -> bool:
|
||||||
|
return len(self._keys) > 1
|
||||||
|
|
||||||
|
@property
|
||||||
|
def all_keys(self) -> list[str]:
|
||||||
|
return list(self._keys)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def active_key(self) -> str | None:
|
||||||
|
"""当前指向的 key(即使正在冷却也返回,用于上报)。"""
|
||||||
|
if not self._keys:
|
||||||
|
return None
|
||||||
|
return self._keys[self._index]
|
||||||
|
|
||||||
|
def get(self) -> str | None:
|
||||||
|
"""获取一个可用 key:优先选不在冷却中的;全部冷却则选最早过期的。"""
|
||||||
|
if not self._keys:
|
||||||
|
return None
|
||||||
|
if len(self._keys) == 1:
|
||||||
|
return self._keys[0]
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
n = len(self._keys)
|
||||||
|
# 从当前 index 开始找一圈,找一个可用的
|
||||||
|
for offset in range(n):
|
||||||
|
idx = (self._index + offset) % n
|
||||||
|
key = self._keys[idx]
|
||||||
|
expire = self._blocked_until.get(key, 0.0)
|
||||||
|
if now >= expire:
|
||||||
|
# 可用:把指针移到这里
|
||||||
|
self._index = idx
|
||||||
|
# 清理已过期的冷却记录
|
||||||
|
if key in self._blocked_until:
|
||||||
|
del self._blocked_until[key]
|
||||||
|
return key
|
||||||
|
|
||||||
|
# 全部在冷却中:选最早过期的那个,并等待到它过期
|
||||||
|
earliest_key = min(self._keys, key=lambda k: self._blocked_until.get(k, 0.0))
|
||||||
|
self._index = self._keys.index(earliest_key)
|
||||||
|
return earliest_key
|
||||||
|
|
||||||
|
def report_rate_limited(self, key: str | None = None) -> str | None:
|
||||||
|
"""上报某个 key 被限流(429)。默认是当前 key。返回切换后的新 key。"""
|
||||||
|
target = key or self.active_key
|
||||||
|
if target and len(self._keys) > 1:
|
||||||
|
until = time.monotonic() + self._cooldown
|
||||||
|
self._blocked_until[target] = until
|
||||||
|
logger.warning(
|
||||||
|
"bzzoiro key 被限流(429),冷却 %.0fs: %s", self._cooldown, _mask(target),
|
||||||
|
)
|
||||||
|
# 轮转到下一个(即使只有一个 key 也做一次 get,保持行为一致)
|
||||||
|
return self.get()
|
||||||
|
|
||||||
|
def wait_if_all_blocked(self) -> float:
|
||||||
|
"""如果所有 key 都在冷却中,返回需要等待的秒数;否则返回 0。"""
|
||||||
|
if len(self._keys) <= 1:
|
||||||
|
return 0.0
|
||||||
|
now = time.monotonic()
|
||||||
|
remaining = [self._blocked_until.get(k, 0.0) - now for k in self._keys]
|
||||||
|
if all(r > -0.001 for r in remaining) and any(r > 0.001 for r in remaining):
|
||||||
|
# 全部仍在冷却中
|
||||||
|
return max(remaining)
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
def stats(self) -> dict:
|
||||||
|
"""当前 key 环状态(用于管理后台展示)。"""
|
||||||
|
now = time.monotonic()
|
||||||
|
return {
|
||||||
|
"total": len(self._keys),
|
||||||
|
"keys": [
|
||||||
|
{
|
||||||
|
"masked": _mask(k),
|
||||||
|
"blocked_remaining": max(0.0, round(self._blocked_until.get(k, 0.0) - now, 1)),
|
||||||
|
}
|
||||||
|
for k in self._keys
|
||||||
|
],
|
||||||
|
"active_index": self._index,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _mask(key: str) -> str:
|
||||||
|
"""脱敏:只显示前 4 位和后 4 位。"""
|
||||||
|
if len(key) <= 10:
|
||||||
|
return key[:2] + "***"
|
||||||
|
return key[:4] + "..." + key[-4:]
|
||||||
|
|
||||||
|
|
||||||
|
# ── 全局单例(进程级,按 base URL 隔离) ──────────────────────────
|
||||||
|
_RINGS: dict[str, KeyRing] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_keys(value: str | None) -> list[str]:
|
||||||
|
"""解析 key 配置值:支持逗号、分号、换行分隔的多个 key。"""
|
||||||
|
if not value:
|
||||||
|
return []
|
||||||
|
# 统一替换分隔符为逗号后拆分
|
||||||
|
normalized = value.replace("\n", ",").replace(";", ",")
|
||||||
|
return [k.strip() for k in normalized.split(",") if k.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def get_key_ring(base: str, raw_keys: str | None, cooldown_seconds: float = DEFAULT_COOLDOWN) -> KeyRing:
|
||||||
|
"""获取(或创建)某 base URL 对应的 KeyRing。"""
|
||||||
|
key = base
|
||||||
|
ring = _RINGS.get(key)
|
||||||
|
parsed = parse_keys(raw_keys)
|
||||||
|
if ring is None:
|
||||||
|
ring = KeyRing(parsed, cooldown_seconds)
|
||||||
|
_RINGS[key] = ring
|
||||||
|
else:
|
||||||
|
# 热更新 key 列表(增删 key 无需重启)
|
||||||
|
if set(ring.all_keys) != set(parsed):
|
||||||
|
ring._keys = parsed
|
||||||
|
ring._blocked_until.clear()
|
||||||
|
ring._index = 0
|
||||||
|
ring._cooldown = cooldown_seconds
|
||||||
|
return ring
|
||||||
@@ -219,35 +219,3 @@ def normalize_bzzoiro(raw: dict, league_type: str) -> NormalizedMatch | None:
|
|||||||
if m.match_status == "finished" and m.home_goals is None:
|
if m.match_status == "finished" and m.home_goals is None:
|
||||||
m.match_status = "scheduled"
|
m.match_status = "scheduled"
|
||||||
return m
|
return m
|
||||||
|
|
||||||
|
|
||||||
def normalize_understat(raw: dict, league_type: str) -> NormalizedMatch | None:
|
|
||||||
"""understat 单场 → NormalizedMatch(仅 xG)。"""
|
|
||||||
from src.data.team_names import normalize as normalize_name
|
|
||||||
|
|
||||||
dt_str = raw.get("datetime") or raw.get("date")
|
|
||||||
if not dt_str:
|
|
||||||
return None
|
|
||||||
dt = _parse_date(dt_str)
|
|
||||||
if dt is None:
|
|
||||||
return None
|
|
||||||
home_info = raw.get("h", {})
|
|
||||||
away_info = raw.get("a", {})
|
|
||||||
home_name = home_info.get("title", "") if isinstance(home_info, dict) else ""
|
|
||||||
away_name = away_info.get("title", "") if isinstance(away_info, dict) else ""
|
|
||||||
home = normalize_name(home_name)
|
|
||||||
away = normalize_name(away_name)
|
|
||||||
if not home or not away or home == away:
|
|
||||||
return 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,
|
|
||||||
home_team=home,
|
|
||||||
away_team=away,
|
|
||||||
match_status="finished",
|
|
||||||
season_label=derive_season_label(dt),
|
|
||||||
home_xg=home_xg,
|
|
||||||
away_xg=away_xg,
|
|
||||||
)
|
|
||||||
|
|||||||
+35
-13
@@ -3,14 +3,18 @@
|
|||||||
定义 DataSource 契约,并提供全局注册表供路由层分发。
|
定义 DataSource 契约,并提供全局注册表供路由层分发。
|
||||||
每个比赛数据源实现该协议,注册后即可通过统一入口调度。
|
每个比赛数据源实现该协议,注册后即可通过统一入口调度。
|
||||||
|
|
||||||
注: injuries 是球员级独立领域(写 Injury 表),不遵循此协议。
|
当前只有 bzzoiro 一个数据源(Understat / injuries 已移除),
|
||||||
|
保留协议与注册表是为了统一 ingest 调度入口的结构。
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from typing import Protocol
|
from typing import Protocol
|
||||||
|
|
||||||
from src.db.base import AsyncSession
|
from src.db.base import AsyncSession
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class DataSource(Protocol):
|
class DataSource(Protocol):
|
||||||
"""比赛数据源契约:抓取 → 规范化 → 入库。"""
|
"""比赛数据源契约:抓取 → 规范化 → 入库。"""
|
||||||
@@ -28,6 +32,9 @@ class DataSource(Protocol):
|
|||||||
# ── 注册表 ──
|
# ── 注册表 ──
|
||||||
_SOURCES: dict[str, DataSource] = {}
|
_SOURCES: dict[str, DataSource] = {}
|
||||||
|
|
||||||
|
# 延迟加载标记:保证 _load_sources() 最多执行一次,重复调用廉价
|
||||||
|
_loaded = False
|
||||||
|
|
||||||
|
|
||||||
def register(source):
|
def register(source):
|
||||||
"""装饰器:将数据源注册到全局注册表。
|
"""装饰器:将数据源注册到全局注册表。
|
||||||
@@ -41,8 +48,7 @@ def register(source):
|
|||||||
|
|
||||||
def get_source(name: str) -> DataSource:
|
def get_source(name: str) -> DataSource:
|
||||||
"""按名获取数据源。"""
|
"""按名获取数据源。"""
|
||||||
if not _SOURCES:
|
_load_sources()
|
||||||
_load_sources()
|
|
||||||
if name not in _SOURCES:
|
if name not in _SOURCES:
|
||||||
raise ValueError(f"未知数据源: {name}")
|
raise ValueError(f"未知数据源: {name}")
|
||||||
return _SOURCES[name]
|
return _SOURCES[name]
|
||||||
@@ -50,19 +56,35 @@ def get_source(name: str) -> DataSource:
|
|||||||
|
|
||||||
def list_sources() -> list[str]:
|
def list_sources() -> list[str]:
|
||||||
"""列出所有已注册数据源名。"""
|
"""列出所有已注册数据源名。"""
|
||||||
if not _SOURCES:
|
_load_sources()
|
||||||
_load_sources()
|
|
||||||
return list(_SOURCES.keys())
|
return list(_SOURCES.keys())
|
||||||
|
|
||||||
|
|
||||||
def _load_sources() -> None:
|
def _load_sources() -> None:
|
||||||
"""延迟导入数据源触发 @register(避免循环导入)。"""
|
"""延迟导入数据源触发 @register(避免循环导入)。
|
||||||
from src.data.bzzoiro import BzzoiroSource # noqa: F811
|
|
||||||
from src.data.understat import UnderstatSource # noqa: F811
|
导入失败必须留下痕迹:静默吞掉 ImportError 会让注册表恒为空,
|
||||||
|
导致 get_source() 对所有数据源都报「未知数据源」,把导入错误
|
||||||
|
伪装成「不存在的名字」—— 这类静默失效极难定位,故在此显式记日志。
|
||||||
|
|
||||||
|
失败时保持 _loaded=False:后续调用会重新尝试导入。正常应用流程中
|
||||||
|
src.api.app 先导入本模块,此处的模块级预热是在 `src.data.bzzoiro`
|
||||||
|
尚未初始化时发起的,导入链在本模块内成环,首次必然失败(bzzoiro
|
||||||
|
仍在加载中),由后续 get_source() / list_sources() 调用完成真正的装载。
|
||||||
|
"""
|
||||||
|
global _loaded
|
||||||
|
if _loaded:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
from src.data.bzzoiro import BzzoiroSource # noqa: F401
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"数据源模块导入失败(通常为初始化中途的环状导入),本次注册表为空;"
|
||||||
|
"下次 get_source()/list_sources() 调用会自动重试"
|
||||||
|
)
|
||||||
|
return # 保持 _loaded=False,下次调用可重试
|
||||||
|
_loaded = True
|
||||||
|
|
||||||
|
|
||||||
# 保持向后兼容:模块加载时尝试加载(但不再强制)
|
# 模块加载时预热(失败会记录日志,不再静默)
|
||||||
try:
|
_load_sources()
|
||||||
_load_sources()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|||||||
@@ -17,11 +17,12 @@ TEAM_NAME_ZH: dict[str, str] = {
|
|||||||
"Nottingham Forest": "诺丁汉森林", "AFC Bournemouth": "伯恩茅斯",
|
"Nottingham Forest": "诺丁汉森林", "AFC Bournemouth": "伯恩茅斯",
|
||||||
"Leeds United": "利兹联", "Leicester City": "莱斯特城",
|
"Leeds United": "利兹联", "Leicester City": "莱斯特城",
|
||||||
"Ipswich Town": "伊普斯维奇", "Southampton": "南安普顿",
|
"Ipswich Town": "伊普斯维奇", "Southampton": "南安普顿",
|
||||||
"Norwich City": "诺维奇城", "Sheffield United": "谢菲尔德联",
|
"Norwich City": "诺维奇", "Norwich": "诺维奇",
|
||||||
|
"Sheffield United": "谢菲尔德联",
|
||||||
"Sheffield Wednesday": "谢周三", "Stoke City": "斯托克城",
|
"Sheffield Wednesday": "谢周三", "Stoke City": "斯托克城",
|
||||||
"Sunderland": "桑德兰", "Burnley": "伯恩利", "Watford": "沃特福德",
|
"Sunderland": "桑德兰", "Burnley": "伯恩利", "Watford": "沃特福德",
|
||||||
"Hull City": "赫尔城", "Huddersfield Town": "哈德斯菲尔德",
|
"Hull City": "赫尔城", "Huddersfield Town": "哈德斯菲尔德",
|
||||||
"Luton Town": "卢顿", "Cardiff City": "加的夫城", "Swansea City": "斯旺西",
|
"Luton Town": "卢顿", "Cardiff City": "加的夫", "Swansea City": "斯旺西",
|
||||||
"West Bromwich Albion": "西布罗姆维奇", "Birmingham City": "伯明翰",
|
"West Bromwich Albion": "西布罗姆维奇", "Birmingham City": "伯明翰",
|
||||||
"Blackburn Rovers": "布莱克本", "Bolton Wanderers": "博尔顿",
|
"Blackburn Rovers": "布莱克本", "Bolton Wanderers": "博尔顿",
|
||||||
"Barnsley": "巴恩斯利", "Blackpool": "布莱克浦",
|
"Barnsley": "巴恩斯利", "Blackpool": "布莱克浦",
|
||||||
@@ -35,17 +36,19 @@ TEAM_NAME_ZH: dict[str, str] = {
|
|||||||
"Celtic": "凯尔特人", "Rangers": "流浪者", "Aberdeen": "阿伯丁",
|
"Celtic": "凯尔特人", "Rangers": "流浪者", "Aberdeen": "阿伯丁",
|
||||||
"Heart of Midlothian": "哈茨", "Hibernian": "希伯尼安",
|
"Heart of Midlothian": "哈茨", "Hibernian": "希伯尼安",
|
||||||
"Derry City": "德里城", "Shelbourne": "谢尔本", "Larne FC": "拉恩",
|
"Derry City": "德里城", "Shelbourne": "谢尔本", "Larne FC": "拉恩",
|
||||||
"Linfield FC": "林斯菲尔德", "Shamrock Rovers": "沙姆罗克流浪者",
|
"Linfield FC": "连菲尔德", "Shamrock Rovers": "沙姆罗克流浪",
|
||||||
# ── 西班牙 ──
|
# ── 西班牙 ──
|
||||||
"Real Madrid": "皇家马德里", "FC Barcelona": "巴塞罗那",
|
"Real Madrid": "皇家马德里", "FC Barcelona": "巴塞罗那",
|
||||||
"Atlético Madrid": "马德里竞技", "Athletic Club": "毕尔巴鄂竞技",
|
"Atlético Madrid": "马德里竞技", "Atletico Madrid": "马德里竞技",
|
||||||
|
"Athletic Club": "毕尔巴鄂竞技",
|
||||||
"Real Sociedad": "皇家社会", "Villarreal": "比利亚雷亚尔",
|
"Real Sociedad": "皇家社会", "Villarreal": "比利亚雷亚尔",
|
||||||
"Real Betis": "皇家贝蒂斯", "Sevilla": "塞维利亚", "Valencia": "瓦伦西亚",
|
"Real Betis": "皇家贝蒂斯", "Sevilla": "塞维利亚", "Valencia": "瓦伦西亚",
|
||||||
"Celta Vigo": "塞尔塔", "Osasuna": "奥萨苏纳", "Getafe": "赫塔菲",
|
"Celta Vigo": "塞尔塔", "Osasuna": "奥萨苏纳", "Getafe": "赫塔菲",
|
||||||
"Rayo Vallecano": "巴列卡诺", "Mallorca": "马略卡", "Girona FC": "赫罗纳",
|
"Rayo Vallecano": "巴列卡诺", "Mallorca": "马略卡", "Girona FC": "赫罗纳",
|
||||||
"Girona": "赫罗纳", "Espanyol": "西班牙人", "UD Las Palmas": "拉斯帕尔马斯",
|
"Girona": "赫罗纳", "Espanyol": "西班牙人", "UD Las Palmas": "拉斯帕尔马斯",
|
||||||
"Las Palmas": "拉斯帕尔马斯", "Deportivo Alavés": "阿拉维斯",
|
"Las Palmas": "拉斯帕尔马斯", "Deportivo Alavés": "阿拉维斯",
|
||||||
"Leganés": "莱加内斯", "Elche": "埃尔切", "Levante UD": "莱万特",
|
"Leganés": "莱加内斯", "Leganes": "莱加内斯",
|
||||||
|
"Elche": "埃尔切", "Levante UD": "莱万特",
|
||||||
"Malaga CF": "马拉加", "Deportivo de A Coruna": "拉科鲁尼亚",
|
"Malaga CF": "马拉加", "Deportivo de A Coruna": "拉科鲁尼亚",
|
||||||
"Real Oviedo": "皇家奥维耶多", "Real Racing Club": "桑坦德竞技",
|
"Real Oviedo": "皇家奥维耶多", "Real Racing Club": "桑坦德竞技",
|
||||||
"Real Valladolid": "巴利亚多利德",
|
"Real Valladolid": "巴利亚多利德",
|
||||||
@@ -59,20 +62,25 @@ TEAM_NAME_ZH: dict[str, str] = {
|
|||||||
"Pisa": "比萨", "Cremonese": "克雷莫纳", "AC Monza": "蒙扎",
|
"Pisa": "比萨", "Cremonese": "克雷莫纳", "AC Monza": "蒙扎",
|
||||||
"Frosinone": "弗罗西诺内", "Sassuolo": "萨索洛",
|
"Frosinone": "弗罗西诺内", "Sassuolo": "萨索洛",
|
||||||
# ── 德国 ──
|
# ── 德国 ──
|
||||||
"FC Bayern Munchen": "拜仁慕尼黑", "Borussia Dortmund": "多特蒙德",
|
"FC Bayern Munchen": "拜仁慕尼黑", "Bayern München": "拜仁慕尼黑",
|
||||||
|
"Borussia Dortmund": "多特蒙德",
|
||||||
"Bayer 04 Leverkusen": "勒沃库森", "RB Leipzig": "莱比锡红牛",
|
"Bayer 04 Leverkusen": "勒沃库森", "RB Leipzig": "莱比锡红牛",
|
||||||
"Borussia Mönchengladbach": "门兴格拉德巴赫", "VfB Stuttgart": "斯图加特",
|
"Borussia Mönchengladbach": "门兴格拉德巴赫", "VfB Stuttgart": "斯图加特",
|
||||||
"Eintracht Frankfurt": "法兰克福", "VfL Wolfsburg": "沃尔夫斯堡",
|
"Eintracht Frankfurt": "法兰克福", "VfL Wolfsburg": "沃尔夫斯堡",
|
||||||
"SC Freiburg": "弗赖堡", "TSG Hoffenheim": "霍芬海姆",
|
"SC Freiburg": "弗赖堡", "TSG Hoffenheim": "霍芬海姆",
|
||||||
"1. FC Union Berlin": "柏林联合", "1. FC Koln": "科隆",
|
"1. FC Union Berlin": "柏林联合", "Union Berlin": "柏林联合",
|
||||||
"1. FSV Mainz 05": "美因茨", "FC Augsburg": "奥格斯堡",
|
"1. FC Koln": "科隆", "FC Köln": "科隆",
|
||||||
"SV Werder Bremen": "云达不来梅", "VfL Bochum 1848": "波鸿",
|
"1. FSV Mainz 05": "美因茨", "Mainz 05": "美因茨",
|
||||||
|
"FC Augsburg": "奥格斯堡",
|
||||||
|
"SV Werder Bremen": "云达不来梅", "Werder Bremen": "云达不来梅",
|
||||||
|
"VfL Bochum 1848": "波鸿", "VfL Bochum": "波鸿",
|
||||||
"1. FC Heidenheim": "海登海姆", "FC St. Pauli": "圣保利",
|
"1. FC Heidenheim": "海登海姆", "FC St. Pauli": "圣保利",
|
||||||
"Holstein Kiel": "荷尔斯泰因基尔", "FC Schalke 04": "沙尔克04",
|
"Holstein Kiel": "荷尔斯泰因基尔", "FC Schalke 04": "沙尔克04",
|
||||||
"Hamburger SV": "汉堡", "SC Paderborn 07": "帕德博恩",
|
"Hamburger SV": "汉堡", "SC Paderborn 07": "帕德博恩",
|
||||||
"SV 07 Elversberg": "埃弗斯贝格",
|
"SV 07 Elversberg": "埃弗斯贝格",
|
||||||
# ── 法国 ──
|
# ── 法国 ──
|
||||||
"Paris Saint-Germain": "巴黎圣日耳曼", "Olympique de Marseille": "马赛",
|
"Paris Saint-Germain": "巴黎圣日耳曼", "Olympique de Marseille": "马赛",
|
||||||
|
"Olympique Marseille": "马赛",
|
||||||
"Olympique Lyonnais": "里昂", "AS Monaco": "摩纳哥", "Lille OSC": "里尔",
|
"Olympique Lyonnais": "里昂", "AS Monaco": "摩纳哥", "Lille OSC": "里尔",
|
||||||
"OGC Nice": "尼斯", "RC Lens": "朗斯", "Stade Rennais": "雷恩",
|
"OGC Nice": "尼斯", "RC Lens": "朗斯", "Stade Rennais": "雷恩",
|
||||||
"RC Strasbourg": "斯特拉斯堡", "Stade Brestois": "布雷斯特",
|
"RC Strasbourg": "斯特拉斯堡", "Stade Brestois": "布雷斯特",
|
||||||
|
|||||||
@@ -1,223 +0,0 @@
|
|||||||
"""Understat xG 数据源。
|
|
||||||
|
|
||||||
迁移自旧项目 app/data/sources/understat.py,改成 async。
|
|
||||||
使用 Repository 模式进行数据访问,不直接控制事务。
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import random
|
|
||||||
import re
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
|
|
||||||
from src.core.http_client import get_client
|
|
||||||
from src.data.config import FDCO_TO_UNDERSTAT, LEAGUE_NAMES
|
|
||||||
from src.data.normalize import normalize_understat
|
|
||||||
from src.data.sources import register
|
|
||||||
from src.db.models import League, Match, MatchStats, Team
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
UNDERSTAT_BASE = "https://understat.com/getLeagueData/{league}/{season}"
|
|
||||||
|
|
||||||
|
|
||||||
async def fetch_understat(league_code: str, season: int) -> list[dict]:
|
|
||||||
"""抓取 understat 单赛季 xG 数据。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
league_code: fdco 风格代码,如 'E0'
|
|
||||||
season: 赛季起始年,如 2025 表示 2025-2026 赛季
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
比赛数组,每项含 datetime/h/a/xG
|
|
||||||
"""
|
|
||||||
understat_league = FDCO_TO_UNDERSTAT.get(league_code)
|
|
||||||
if understat_league is None:
|
|
||||||
raise ValueError(f"未知联赛代码: {league_code}")
|
|
||||||
|
|
||||||
url = UNDERSTAT_BASE.format(league=understat_league, season=season)
|
|
||||||
headers = {
|
|
||||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36",
|
|
||||||
"X-Requested-With": "XMLHttpRequest",
|
|
||||||
"Referer": f"https://understat.com/league/{understat_league}/{season}",
|
|
||||||
}
|
|
||||||
|
|
||||||
# 重试:网络错误 / 5xx / 429
|
|
||||||
last_exc: Exception | None = None
|
|
||||||
for attempt in range(3):
|
|
||||||
try:
|
|
||||||
client = get_client()
|
|
||||||
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:
|
|
||||||
last_exc = e
|
|
||||||
if attempt == 2:
|
|
||||||
raise
|
|
||||||
delay = min(2 ** attempt, 8) + random.uniform(0, 1)
|
|
||||||
logger.warning("understat fetch failed, retry %d in %.1fs: %s", attempt + 1, delay, e)
|
|
||||||
await asyncio.sleep(delay)
|
|
||||||
else:
|
|
||||||
raise RuntimeError(f"understat fetch failed: {last_exc}")
|
|
||||||
|
|
||||||
# 优先按 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
|
|
||||||
match = re.search(r"var\s+datesData\s*=\s*JSON\.parse\('([^']+)'\)", text)
|
|
||||||
if not match:
|
|
||||||
logger.warning("understat 响应格式不符: %s...", text[:200])
|
|
||||||
return []
|
|
||||||
decoded = match.group(1).encode().decode("unicode_escape")
|
|
||||||
data = json.loads(decoded)
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
def _match_key(home_team_id: int, away_team_id: int, match_date) -> tuple[int, int, str]:
|
|
||||||
"""比赛去重键:(主队, 客队, 天级日期 ISO 字符串)。
|
|
||||||
|
|
||||||
统一在这里构造,避免"预加载时用 str(date)、写入时用 isoformat()"这类
|
|
||||||
隐式格式依赖 —— 两者当前恰好相等,但一旦有人改动其一就会静默失配,
|
|
||||||
导致所有比赛被判为不存在而重复插入。
|
|
||||||
"""
|
|
||||||
if hasattr(match_date, "date") and callable(match_date.date):
|
|
||||||
match_date = match_date.date()
|
|
||||||
return (home_team_id, away_team_id, match_date.isoformat() if match_date is not None else "")
|
|
||||||
|
|
||||||
|
|
||||||
@register
|
|
||||||
class UnderstatSource:
|
|
||||||
"""understat xG 数据源(实现 DataSource 协议)。"""
|
|
||||||
|
|
||||||
name = "understat"
|
|
||||||
|
|
||||||
async def ingest(self, db, *, league: str, season: int) -> dict:
|
|
||||||
"""采集 understat xG → 回填到现有 Match。只回填 xG 字段,不创建新 Match。
|
|
||||||
|
|
||||||
注意: 本方法不控制事务(commit/rollback),由调用方通过 UnitOfWork 控制。
|
|
||||||
|
|
||||||
P1-3: 批量查询优化,将单赛季 380 场 × 3 次 DB 往返降为 3 次查询。
|
|
||||||
"""
|
|
||||||
from src.db.repositories import LeagueRepository, TeamRepository
|
|
||||||
|
|
||||||
result = {"updated": 0, "skipped": 0, "unmatched": 0, "errors": []}
|
|
||||||
|
|
||||||
try:
|
|
||||||
raw_matches = await fetch_understat(league, season)
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception("understat fetch failed for %s %s", league, season)
|
|
||||||
result["errors"].append(f"fetch failed: {e}")
|
|
||||||
return result
|
|
||||||
|
|
||||||
# 使用 Repository
|
|
||||||
league_repo = LeagueRepository(db)
|
|
||||||
team_repo = TeamRepository(db)
|
|
||||||
|
|
||||||
# 查联赛
|
|
||||||
league_obj = await league_repo.get_by_code(league)
|
|
||||||
if league_obj is None:
|
|
||||||
result["errors"].append(f"league {league} not found in DB")
|
|
||||||
return result
|
|
||||||
|
|
||||||
# === 批量优化: 一次规范化,收集球队名和日期 ===
|
|
||||||
normalized_matches: list = []
|
|
||||||
all_team_names: set[str] = set()
|
|
||||||
for raw in raw_matches:
|
|
||||||
if not raw.get("isResult"):
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
nm = normalize_understat(raw, league)
|
|
||||||
if nm is None:
|
|
||||||
result["skipped"] += 1
|
|
||||||
continue
|
|
||||||
except Exception as e:
|
|
||||||
result["errors"].append(f"normalize: {e}")
|
|
||||||
continue
|
|
||||||
normalized_matches.append((nm, raw))
|
|
||||||
all_team_names.add(nm.home_team)
|
|
||||||
all_team_names.add(nm.away_team)
|
|
||||||
|
|
||||||
if not normalized_matches:
|
|
||||||
return result
|
|
||||||
|
|
||||||
# === 批量查询球队(1 次 DB 往返) ===
|
|
||||||
team_name_to_id = {}
|
|
||||||
if all_team_names:
|
|
||||||
teams = await team_repo.get_all_by_names(list(all_team_names))
|
|
||||||
team_name_to_id = {name: team.id for name, team in teams.items()}
|
|
||||||
|
|
||||||
# === 批量查询已有比赛(1 次 DB 往返,按日期范围) ===
|
|
||||||
match_dict: dict[tuple, Match] = {}
|
|
||||||
dates = [nm.date for nm, _ 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)
|
|
||||||
stmt = (
|
|
||||||
select(Match)
|
|
||||||
.options(selectinload(Match.stats))
|
|
||||||
.where(Match.league_id == league_obj.id)
|
|
||||||
.where(Match.match_date >= min_dt)
|
|
||||||
.where(Match.match_date <= max_dt)
|
|
||||||
)
|
|
||||||
for m in (await db.execute(stmt)).scalars():
|
|
||||||
key = _match_key(m.home_team_id, m.away_team_id, m.match_date_date)
|
|
||||||
match_dict[key] = m
|
|
||||||
|
|
||||||
# === 内存匹配 + 回填 xG ===
|
|
||||||
for nm, raw in normalized_matches:
|
|
||||||
home_team_id = team_name_to_id.get(nm.home_team)
|
|
||||||
away_team_id = team_name_to_id.get(nm.away_team)
|
|
||||||
if home_team_id is None or away_team_id is None:
|
|
||||||
result["unmatched"] += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
match_key = _match_key(home_team_id, away_team_id, nm.date)
|
|
||||||
existing = match_dict.get(match_key)
|
|
||||||
if existing is None:
|
|
||||||
result["unmatched"] += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 回填 xG
|
|
||||||
if existing.stats is None and (nm.home_xg is not None or nm.away_xg is not None):
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
# available_at 语义:统计「可被使用」的最早时间,至少不早于比赛结束
|
|
||||||
# 近似:开球 + 2 小时(实际完赛时间约为 +2 小时,非官方公布时间)
|
|
||||||
match_date = existing.match_date if existing.match_date else now
|
|
||||||
available_at = match_date + timedelta(hours=2)
|
|
||||||
existing.stats = MatchStats(
|
|
||||||
match_id=existing.id,
|
|
||||||
source="understat",
|
|
||||||
source_record_id=str(raw.get("id", "")),
|
|
||||||
retrieved_at=now,
|
|
||||||
available_at=available_at,
|
|
||||||
)
|
|
||||||
db.add(existing.stats)
|
|
||||||
await db.flush()
|
|
||||||
if existing.stats is not None:
|
|
||||||
if existing.stats.home_xg is None and nm.home_xg is not None:
|
|
||||||
existing.stats.home_xg = nm.home_xg
|
|
||||||
result["updated"] += 1
|
|
||||||
if existing.stats.away_xg is None and nm.away_xg is not None:
|
|
||||||
existing.stats.away_xg = nm.away_xg
|
|
||||||
|
|
||||||
# 注意: 不在此处 commit,由调用方 UnitOfWork 控制事务
|
|
||||||
return result
|
|
||||||
+95
-36
@@ -1,4 +1,7 @@
|
|||||||
"""6 张表 ORM: leagues / teams / matches / match_stats / predictions / injuries。"""
|
"""ORM 模型: leagues / teams / matches / match_stats / standings / predictions。
|
||||||
|
|
||||||
|
数据源统一为 bzzoiro(单一数据源),伤停(injuries)与 Understat 已移除。
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import date, datetime, timezone
|
from datetime import date, datetime, timezone
|
||||||
@@ -16,7 +19,6 @@ from sqlalchemy import (
|
|||||||
String,
|
String,
|
||||||
Text,
|
Text,
|
||||||
UniqueConstraint,
|
UniqueConstraint,
|
||||||
and_,
|
|
||||||
func,
|
func,
|
||||||
)
|
)
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
@@ -75,6 +77,8 @@ class Match(Base):
|
|||||||
home_ht_goals: Mapped[int | None] = mapped_column(Integer)
|
home_ht_goals: Mapped[int | None] = mapped_column(Integer)
|
||||||
away_ht_goals: Mapped[int | None] = mapped_column(Integer)
|
away_ht_goals: Mapped[int | None] = mapped_column(Integer)
|
||||||
match_stage: Mapped[str | None] = mapped_column(String(100))
|
match_stage: Mapped[str | None] = mapped_column(String(100))
|
||||||
|
# 数据血缘:bzzoiro 上游事件 ID,用于 /events/{id}/stats/ 统计回填
|
||||||
|
source_event_id: Mapped[int | None] = mapped_column(BigInteger, index=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
|
||||||
|
|
||||||
@@ -90,7 +94,7 @@ class Match(Base):
|
|||||||
foreign_keys=[away_team_id], back_populates="away_matches", lazy="selectin"
|
foreign_keys=[away_team_id], back_populates="away_matches", lazy="selectin"
|
||||||
)
|
)
|
||||||
stats: Mapped["MatchStats | None"] = relationship(
|
stats: Mapped["MatchStats | None"] = relationship(
|
||||||
back_populates="match", cascade="all, delete-orphan", lazy="selectin"
|
back_populates="match", cascade="all, delete-orphan", lazy="select"
|
||||||
)
|
)
|
||||||
predictions: Mapped[list["Prediction"]] = relationship(back_populates="match", cascade="all, delete-orphan")
|
predictions: Mapped[list["Prediction"]] = relationship(back_populates="match", cascade="all, delete-orphan")
|
||||||
|
|
||||||
@@ -107,6 +111,24 @@ class Match(Base):
|
|||||||
"match_date_date",
|
"match_date_date",
|
||||||
unique=True,
|
unique=True,
|
||||||
),
|
),
|
||||||
|
# DB-5: 数据库级约束 — 已完赛比赛必须有比分
|
||||||
|
CheckConstraint(
|
||||||
|
"match_status <> 'finished' OR (home_goals IS NOT NULL AND away_goals IS NOT NULL)",
|
||||||
|
name="ck_matches_finished_has_score",
|
||||||
|
),
|
||||||
|
CheckConstraint(
|
||||||
|
"match_status IN ('finished', 'scheduled', 'in_play', 'paused', 'postponed', 'cancelled', 'suspended')",
|
||||||
|
name="ck_matches_status_enum",
|
||||||
|
),
|
||||||
|
# 半场进球 ≤ 全场进球
|
||||||
|
CheckConstraint(
|
||||||
|
"home_ht_goals IS NULL OR home_goals IS NULL OR home_ht_goals <= home_goals",
|
||||||
|
name="ck_matches_home_ht_le_full",
|
||||||
|
),
|
||||||
|
CheckConstraint(
|
||||||
|
"away_ht_goals IS NULL OR away_goals IS NULL OR away_ht_goals <= away_goals",
|
||||||
|
name="ck_matches_away_ht_le_full",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -127,6 +149,11 @@ class MatchStats(Base):
|
|||||||
away_yellow_cards: Mapped[int | None] = mapped_column(Integer)
|
away_yellow_cards: Mapped[int | None] = mapped_column(Integer)
|
||||||
home_red_cards: Mapped[int | None] = mapped_column(Integer)
|
home_red_cards: Mapped[int | None] = mapped_column(Integer)
|
||||||
away_red_cards: Mapped[int | None] = mapped_column(Integer)
|
away_red_cards: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
# bzzoiro /events/{id}/stats/ 扩展字段
|
||||||
|
home_big_chances: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
away_big_chances: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
home_fouls: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
away_fouls: Mapped[int | None] = mapped_column(Integer)
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
|
||||||
# 数据血缘:追踪统计数据的来源和可用时间
|
# 数据血缘:追踪统计数据的来源和可用时间
|
||||||
source: Mapped[str | None] = mapped_column(String(30)) # bzzoiro / understat
|
source: Mapped[str | None] = mapped_column(String(30)) # bzzoiro / understat
|
||||||
@@ -146,39 +173,40 @@ class MatchStats(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class Injury(Base):
|
class Standing(Base):
|
||||||
"""球员伤停记录(api-football 数据源)。"""
|
"""联赛积分榜快照(bzzoiro /leagues/{id}/standings/)。
|
||||||
__tablename__ = "injuries"
|
|
||||||
|
同一联赛同一赛季只保留最新快照:重新采集时按 (league_id, season, team_id)
|
||||||
|
upsert。zone 来自 bzzoiro 分区(如 champions_league / europa_league / relegation)。
|
||||||
|
"""
|
||||||
|
__tablename__ = "standings"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
player_id: Mapped[int | None] = mapped_column(Integer, index=True)
|
league_id: Mapped[int] = mapped_column(ForeignKey("leagues.id"), nullable=False)
|
||||||
player_name: Mapped[str] = mapped_column(String(120), nullable=False)
|
season: Mapped[str] = mapped_column(String(12), nullable=False)
|
||||||
team_id: Mapped[int | None] = mapped_column(ForeignKey("teams.id"), index=True)
|
team_id: Mapped[int] = mapped_column(ForeignKey("teams.id"), nullable=False)
|
||||||
fixture_id: Mapped[int | None] = mapped_column(Integer)
|
position: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
league_id: Mapped[int | None] = mapped_column(Integer)
|
played: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
injury_type: Mapped[str | None] = mapped_column(String(50)) # Missing Fixture / Suspended
|
won: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
reason: Mapped[str | None] = mapped_column(String(200))
|
drawn: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
injury_date: Mapped[date | None] = mapped_column(Date, index=True)
|
lost: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
return_date: Mapped[date | None] = mapped_column(Date)
|
goals_for: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
goals_against: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
goal_diff: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
points: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
xg_for: Mapped[float | None] = mapped_column(Float)
|
||||||
|
xg_against: Mapped[float | None] = mapped_column(Float)
|
||||||
|
form: Mapped[str | None] = mapped_column(String(20)) # 近期赛果串,如 "WWDLW"
|
||||||
|
zone: Mapped[str | None] = mapped_column(String(50)) # champions_league / relegation 等
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
|
||||||
retrieved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
retrieved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
||||||
|
|
||||||
team: Mapped["Team | None"] = relationship()
|
league: Mapped[League] = relationship()
|
||||||
|
team: Mapped[Team] = relationship(lazy="selectin")
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
# Fix 4: partial unique index — 只在 player_id 和 fixture_id 都非空时强制唯一
|
UniqueConstraint("league_id", "season", "team_id", name="uq_standings_league_season_team"),
|
||||||
# PostgreSQL 中 NULL != NULL,普通唯一索引无法防止 NULL 重复
|
Index("ix_standings_league_season_pos", "league_id", "season", "position"),
|
||||||
Index(
|
|
||||||
"ix_injuries_player_fixture",
|
|
||||||
"player_id",
|
|
||||||
"fixture_id",
|
|
||||||
"injury_type",
|
|
||||||
unique=True,
|
|
||||||
postgresql_where=and_(
|
|
||||||
player_id.is_not(None),
|
|
||||||
fixture_id.is_not(None),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Index("ix_injuries_team_date", "team_id", "injury_date"),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -186,7 +214,7 @@ class Prediction(Base):
|
|||||||
__tablename__ = "predictions"
|
__tablename__ = "predictions"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
match_id: Mapped[int] = mapped_column(ForeignKey("matches.id"), nullable=False)
|
match_id: Mapped[int] = mapped_column(ForeignKey("matches.id", ondelete="CASCADE"), nullable=False)
|
||||||
provider: Mapped[str] = mapped_column(String(30), nullable=False)
|
provider: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||||
model: Mapped[str] = mapped_column(String(80), nullable=False)
|
model: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||||
prompt_version: Mapped[str] = mapped_column(String(20), nullable=False, default="v1")
|
prompt_version: Mapped[str] = mapped_column(String(20), nullable=False, default="v1")
|
||||||
@@ -239,7 +267,7 @@ class Prediction(Base):
|
|||||||
CheckConstraint("pred_away_goals >= 0", name="ck_pred_away_goals_nonneg"),
|
CheckConstraint("pred_away_goals >= 0", name="ck_pred_away_goals_nonneg"),
|
||||||
CheckConstraint("subjective_confidence >= 0 AND subjective_confidence <= 1", name="ck_confidence_range"),
|
CheckConstraint("subjective_confidence >= 0 AND subjective_confidence <= 1", name="ck_confidence_range"),
|
||||||
CheckConstraint("pred_1x2 IN ('1', 'X', '2')", name="ck_pred_1x2_enum"),
|
CheckConstraint("pred_1x2 IN ('1', 'X', '2')", name="ck_pred_1x2_enum"),
|
||||||
CheckConstraint("mode IN ('single', 'multi')", name="ck_mode_enum"),
|
CheckConstraint("mode IN ('single', 'multi', 'baseline')", name="ck_mode_enum"),
|
||||||
CheckConstraint("status IN ('success', 'failed', 'degraded')", name="ck_status_enum"),
|
CheckConstraint("status IN ('success', 'failed', 'degraded')", name="ck_status_enum"),
|
||||||
CheckConstraint("run_type IN ('live', 'backtest')", name="ck_run_type_enum"),
|
CheckConstraint("run_type IN ('live', 'backtest')", name="ck_run_type_enum"),
|
||||||
)
|
)
|
||||||
@@ -254,12 +282,31 @@ class AppSetting(Base):
|
|||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class Schedule(Base):
|
||||||
|
"""定时采集任务配置。"""
|
||||||
|
__tablename__ = "schedules"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(50), primary_key=True)
|
||||||
|
task: Mapped[str] = mapped_column(String(20), nullable=False) # events / standings / stats / all
|
||||||
|
cron: Mapped[str] = mapped_column(String(100), nullable=False) # cron 表达式
|
||||||
|
leagues: Mapped[str | None] = mapped_column(Text) # 逗号分隔的联赛代码,空=全部
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
last_run_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
last_status: Mapped[str | None] = mapped_column(String(20)) # success / failed
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
|
||||||
|
|
||||||
|
|
||||||
# ── 数据管线基础设施(对应迁移 0008) ──────────────────────────────────
|
# ── 数据管线基础设施(对应迁移 0008) ──────────────────────────────────
|
||||||
# Bronze 层、死信、质量监控、血缘追踪 4 张表。
|
# Bronze 层、死信、质量监控、血缘追踪 4 张表。
|
||||||
|
|
||||||
|
|
||||||
class RawEvent(Base):
|
class RawEvent(Base):
|
||||||
"""Bronze 层:采集到的原始事件存档,便于重放与审计。"""
|
"""Bronze 层:采集到的原始事件存档,便于重放与审计。
|
||||||
|
|
||||||
|
⚠️ 预留未启用:当前 bzzoiro 管线不写入此表。
|
||||||
|
未来接线计划:events 采集成功后写入 raw_payload,支持重放与审计。
|
||||||
|
"""
|
||||||
__tablename__ = "raw_events"
|
__tablename__ = "raw_events"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||||
@@ -276,7 +323,11 @@ class RawEvent(Base):
|
|||||||
|
|
||||||
|
|
||||||
class IngestFailure(Base):
|
class IngestFailure(Base):
|
||||||
"""采集失败死信:记录失败原因、重试次数与下次重试时间。"""
|
"""采集失败死信:记录失败原因、重试次数与下次重试时间。
|
||||||
|
|
||||||
|
⚠️ 预留未启用:当前 bzzoiro 管线不写入此表。
|
||||||
|
未来接线计划:采集失败时写入,支持按 next_retry_at 自动重试。
|
||||||
|
"""
|
||||||
__tablename__ = "ingest_failures"
|
__tablename__ = "ingest_failures"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||||
@@ -302,7 +353,11 @@ class IngestFailure(Base):
|
|||||||
|
|
||||||
|
|
||||||
class DataQualityCheck(Base):
|
class DataQualityCheck(Base):
|
||||||
"""数据质量监控:记录每次质量检查的结果。"""
|
"""数据质量监控:记录每次质量检查的结果。
|
||||||
|
|
||||||
|
⚠️ 预留未启用:当前 bzzoiro 管线不写入此表。
|
||||||
|
未来接线计划:定时检查比赛/统计/积分榜完整性,写入检查结果。
|
||||||
|
"""
|
||||||
__tablename__ = "data_quality_checks"
|
__tablename__ = "data_quality_checks"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||||
@@ -323,7 +378,11 @@ class DataQualityCheck(Base):
|
|||||||
|
|
||||||
|
|
||||||
class DataLineage(Base):
|
class DataLineage(Base):
|
||||||
"""ETL 血缘追踪:记录从源到目标的转换过程。"""
|
"""ETL 血缘追踪:记录从源到目标的转换过程。
|
||||||
|
|
||||||
|
⚠️ 预留未启用:当前 bzzoiro 管线不写入此表。
|
||||||
|
未来接线计划:每次采集写入 source_record_id → target_table/id 映射。
|
||||||
|
"""
|
||||||
__tablename__ = "data_lineage"
|
__tablename__ = "data_lineage"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class MatchRepository:
|
|||||||
) -> Match | None:
|
) -> Match | None:
|
||||||
"""按联赛+主队+客队+日期查找比赛(天级匹配)。
|
"""按联赛+主队+客队+日期查找比赛(天级匹配)。
|
||||||
|
|
||||||
预加载 stats:调用方(understat 回填)会读取 existing.stats,
|
预加载 stats:调用方(统计回填)会读取 existing.stats,
|
||||||
async session 下惰性加载会抛 MissingGreenlet。
|
async session 下惰性加载会抛 MissingGreenlet。
|
||||||
|
|
||||||
P2-3: 使用 match_date_date(已建索引)做等值匹配,避免 func.date()
|
P2-3: 使用 match_date_date(已建索引)做等值匹配,避免 func.date()
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ def load_agent_prompt(name: str, version: str = "v1") -> str:
|
|||||||
@dataclass
|
@dataclass
|
||||||
class AgentSpec:
|
class AgentSpec:
|
||||||
"""领域专家 agent 定义。"""
|
"""领域专家 agent 定义。"""
|
||||||
name: str # h2h / form / home_away / injuries / stats
|
name: str # h2h / form / home_away / standings / stats
|
||||||
system_prompt: str # system message
|
system_prompt: str # system message
|
||||||
slice_fn: object # async (header, before) -> str 切片函数
|
slice_fn: object # async (header, before) -> str 切片函数
|
||||||
|
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ from src.llm.context_builder import (
|
|||||||
h2h_slice,
|
h2h_slice,
|
||||||
header_text,
|
header_text,
|
||||||
home_away_slice,
|
home_away_slice,
|
||||||
injuries_slice,
|
|
||||||
load_match_header,
|
load_match_header,
|
||||||
|
standings_slice,
|
||||||
stats_slice,
|
stats_slice,
|
||||||
)
|
)
|
||||||
from src.core.runtime_config import get_runtime_value
|
from src.core.runtime_config import get_runtime_value
|
||||||
@@ -36,7 +36,7 @@ _AGENT_PROVIDER_CACHE_TTL = 60.0
|
|||||||
|
|
||||||
|
|
||||||
# ── 5 个专家 agent 定义 ──
|
# ── 5 个专家 agent 定义 ──
|
||||||
# A=近期状态 B=攻防数据 C=主客因素 D=阵容完整性 E=历史交锋
|
# A=近期状态 B=攻防数据 C=主客因素 D=联赛排名 E=历史交锋
|
||||||
SPECIALIST_SPECS: list[AgentSpec] = [
|
SPECIALIST_SPECS: list[AgentSpec] = [
|
||||||
AgentSpec(
|
AgentSpec(
|
||||||
name="form",
|
name="form",
|
||||||
@@ -54,9 +54,9 @@ SPECIALIST_SPECS: list[AgentSpec] = [
|
|||||||
slice_fn=home_away_slice,
|
slice_fn=home_away_slice,
|
||||||
),
|
),
|
||||||
AgentSpec(
|
AgentSpec(
|
||||||
name="injuries",
|
name="standings",
|
||||||
system_prompt="你是足球阵容完整性分析专家。汇总伤停与停赛名单,输出战力缺失程度。只输出 JSON。",
|
system_prompt="你是足球联赛排名分析专家。分析积分榜位置、积分走势与分区,评估两队整体实力差距。只输出 JSON。",
|
||||||
slice_fn=injuries_slice,
|
slice_fn=standings_slice,
|
||||||
),
|
),
|
||||||
AgentSpec(
|
AgentSpec(
|
||||||
name="h2h",
|
name="h2h",
|
||||||
@@ -76,7 +76,7 @@ AGENT_LABELS_ZH: dict[str, str] = {
|
|||||||
"form": "近期状态分析专家",
|
"form": "近期状态分析专家",
|
||||||
"stats": "攻防数据分析专家",
|
"stats": "攻防数据分析专家",
|
||||||
"home_away": "主客因素分析专家",
|
"home_away": "主客因素分析专家",
|
||||||
"injuries": "阵容完整性分析专家",
|
"standings": "联赛排名分析专家",
|
||||||
"h2h": "历史交锋分析专家",
|
"h2h": "历史交锋分析专家",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,31 +95,34 @@ class MultiPredictResult:
|
|||||||
pred_1x2: str | None
|
pred_1x2: str | None
|
||||||
subjective_confidence: float | None
|
subjective_confidence: float | None
|
||||||
reasoning: str | None
|
reasoning: str | None
|
||||||
|
context: str
|
||||||
agent_outputs: list[dict]
|
agent_outputs: list[dict]
|
||||||
agent_weights: dict | None
|
agent_weights: dict | None
|
||||||
status: str = "success"
|
status: str = "success"
|
||||||
context: str
|
|
||||||
latency_ms: int | None = None
|
latency_ms: int | None = None
|
||||||
prompt_tokens: int | None = None
|
prompt_tokens: int | None = None
|
||||||
completion_tokens: int | None = None
|
completion_tokens: int | None = None
|
||||||
raw: dict | None = None
|
raw: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
async def _agent_provider(agent_id: str, *, tier: str) -> LLMProvider:
|
async def _agent_provider(agent_id: str, *, tier: str, model_override: str | None = None) -> LLMProvider:
|
||||||
"""构造某 agent 专属 provider。
|
"""构造某 agent 专属 provider。
|
||||||
|
|
||||||
覆盖优先级:
|
覆盖优先级:
|
||||||
模型: AGENT_MODEL_{ID}(运行时) → 层级默认(LLM_SPECIALIST/AGGREGATOR_MODEL) → 全局 LLM_MODEL
|
模型: model_override(调用方显式指定) → AGENT_MODEL_{ID}(运行时) → 层级默认(LLM_SPECIALIST/AGGREGATOR_MODEL) → 全局 LLM_MODEL
|
||||||
地址/密钥: AGENT_BASE_URL_{ID} / AGENT_API_KEY_{ID}(运行时) → 全局 LLM_BASE_URL / LLM_API_KEY
|
地址/密钥: AGENT_BASE_URL_{ID} / AGENT_API_KEY_{ID}(运行时) → 全局 LLM_BASE_URL / LLM_API_KEY
|
||||||
|
|
||||||
P3-2: 结果缓存 60 秒,避免每次预测都多次查询运行时配置 DB。
|
P3-2: 结果缓存 60 秒,避免每次预测都多次查询运行时配置 DB。
|
||||||
|
注意:model_override 生效时跳过缓存读写 —— 否则带 override 的结果会泄漏给
|
||||||
|
不带 override 的调用(反之亦然),导致跨调用的模型串味。
|
||||||
"""
|
"""
|
||||||
cache_key = f"{agent_id}:{tier}"
|
cache_key = f"{agent_id}:{tier}"
|
||||||
cached = _AGENT_PROVIDER_CACHE.get(cache_key)
|
if model_override is None:
|
||||||
if cached is not None:
|
cached = _AGENT_PROVIDER_CACHE.get(cache_key)
|
||||||
ts, provider = cached
|
if cached is not None:
|
||||||
if time.time() - ts < _AGENT_PROVIDER_CACHE_TTL:
|
ts, provider = cached
|
||||||
return provider
|
if time.time() - ts < _AGENT_PROVIDER_CACHE_TTL:
|
||||||
|
return provider
|
||||||
|
|
||||||
pfx = f"AGENT_{agent_id.upper()}_"
|
pfx = f"AGENT_{agent_id.upper()}_"
|
||||||
p = await get_default_provider()
|
p = await get_default_provider()
|
||||||
@@ -129,6 +132,9 @@ async def _agent_provider(agent_id: str, *, tier: str) -> LLMProvider:
|
|||||||
model = await get_runtime_value(f"{pfx}MODEL")
|
model = await get_runtime_value(f"{pfx}MODEL")
|
||||||
if model:
|
if model:
|
||||||
p.model = model
|
p.model = model
|
||||||
|
# 调用方显式传入的 model 优先级最高,高于 agent 级与层级默认
|
||||||
|
if model_override:
|
||||||
|
p.model = model_override
|
||||||
base = await get_runtime_value(f"{pfx}BASE_URL")
|
base = await get_runtime_value(f"{pfx}BASE_URL")
|
||||||
if base:
|
if base:
|
||||||
p.base_url = base
|
p.base_url = base
|
||||||
@@ -136,10 +142,11 @@ async def _agent_provider(agent_id: str, *, tier: str) -> LLMProvider:
|
|||||||
if key:
|
if key:
|
||||||
p.api_key = key
|
p.api_key = key
|
||||||
|
|
||||||
_AGENT_PROVIDER_CACHE[cache_key] = (time.time(), p)
|
if model_override is None:
|
||||||
# 简单淘汰:超过 20 条时清空(60s TTL 下不会累积太多)
|
_AGENT_PROVIDER_CACHE[cache_key] = (time.time(), p)
|
||||||
if len(_AGENT_PROVIDER_CACHE) > 20:
|
# 简单淘汰:超过 20 条时清空(60s TTL 下不会累积太多)
|
||||||
_AGENT_PROVIDER_CACHE.clear()
|
if len(_AGENT_PROVIDER_CACHE) > 20:
|
||||||
|
_AGENT_PROVIDER_CACHE.clear()
|
||||||
return p
|
return p
|
||||||
|
|
||||||
|
|
||||||
@@ -148,13 +155,19 @@ async def run_specialists(
|
|||||||
*,
|
*,
|
||||||
version: str = "v1",
|
version: str = "v1",
|
||||||
before=None,
|
before=None,
|
||||||
|
model_override: str | None = None,
|
||||||
) -> list[AgentReport]:
|
) -> list[AgentReport]:
|
||||||
"""并行执行 5 个专家 agent。fail-open: 单个失败不影响其他。
|
"""并行执行 5 个专家 agent。fail-open: 单个失败不影响其他。
|
||||||
|
|
||||||
before: 数据截止时间(回测防泄漏)。None 表示不限制。
|
before: 数据截止时间(回测防泄漏)。None 表示不限制。
|
||||||
|
model_override: 调用方显式指定的模型,覆盖各 agent 的层级默认。
|
||||||
|
|
||||||
|
注意:model_override 必须作为形参下传,不能用模块级变量中转。
|
||||||
|
backtest 会 asyncio.gather 并发 8 场预测(见 backtest.py 的 Semaphore(8)),
|
||||||
|
模块级变量会被并发调用互相覆盖,导致 A 场的预测用上 B 场的模型。
|
||||||
"""
|
"""
|
||||||
tasks = [
|
tasks = [
|
||||||
_run_one(spec, header, await _agent_provider(spec.name, tier="specialist"), version=version, before=before)
|
_run_one(spec, header, await _agent_provider(spec.name, tier="specialist", model_override=model_override), version=version, before=before)
|
||||||
for spec in SPECIALIST_SPECS
|
for spec in SPECIALIST_SPECS
|
||||||
]
|
]
|
||||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
@@ -222,11 +235,13 @@ async def predict_match_multi(
|
|||||||
version: str = "v1",
|
version: str = "v1",
|
||||||
backtest: bool = False,
|
backtest: bool = False,
|
||||||
cutoff_at=None,
|
cutoff_at=None,
|
||||||
|
model: str | None = None,
|
||||||
) -> MultiPredictResult:
|
) -> MultiPredictResult:
|
||||||
"""多 agent 端到端预测: 切片 → 并行专家 → 终裁 → 存库。
|
"""多 agent 端到端预测: 切片 → 并行专家 → 终裁 → 存库。
|
||||||
|
|
||||||
backtest: 回测模式。True 时 cutoff 自动设为 match_dt - 1 天。
|
backtest: 回测模式。True 时 cutoff 自动设为 match_dt - 1 天。
|
||||||
cutoff_at: 显式截止时间(优先于 backtest 自动计算)。
|
cutoff_at: 显式截止时间(优先于 backtest 自动计算)。
|
||||||
|
model: 显式指定模型,优先于 agent 级/层级默认配置(single 模式语义一致)。
|
||||||
"""
|
"""
|
||||||
start = time.perf_counter()
|
start = time.perf_counter()
|
||||||
|
|
||||||
@@ -247,7 +262,11 @@ async def predict_match_multi(
|
|||||||
prediction_cutoff_at = cutoff
|
prediction_cutoff_at = cutoff
|
||||||
|
|
||||||
# 2. 并行专家(各自独立配置,使用统一 cutoff)
|
# 2. 并行专家(各自独立配置,使用统一 cutoff)
|
||||||
reports = await run_specialists(header, version=version, before=cutoff)
|
# model 作为形参下传,而非模块级变量:backtest 并发 8 场预测时,
|
||||||
|
# 模块级变量会被并发调用互相覆盖(模型串味)。
|
||||||
|
reports = await run_specialists(
|
||||||
|
header, version=version, before=cutoff, model_override=model
|
||||||
|
)
|
||||||
|
|
||||||
# 2.5 统计有效专家报告数量
|
# 2.5 统计有效专家报告数量
|
||||||
ok_reports = [r for r in reports if r.status == "ok"]
|
ok_reports = [r for r in reports if r.status == "ok"]
|
||||||
@@ -263,7 +282,7 @@ async def predict_match_multi(
|
|||||||
# 所有专家无数据/均失败:跳过终裁,标记 degraded
|
# 所有专家无数据/均失败:跳过终裁,标记 degraded
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"预测降级 match=%s mode=%s status=degraded experts=%d/%d 均无有效数据",
|
"预测降级 match=%s mode=%s status=degraded experts=%d/%d 均无有效数据",
|
||||||
match_id, "multi", ok_reports, len(reports),
|
match_id, "multi", len(ok_reports), len(reports),
|
||||||
)
|
)
|
||||||
# 无有效专家时不调用 aggregator provider,避免多余开销
|
# 无有效专家时不调用 aggregator provider,避免多余开销
|
||||||
# model 使用 settings 默认值占位(无实际 LLM 调用)
|
# model 使用 settings 默认值占位(无实际 LLM 调用)
|
||||||
@@ -279,7 +298,7 @@ async def predict_match_multi(
|
|||||||
}
|
}
|
||||||
agg_prompt_tokens = 0
|
agg_prompt_tokens = 0
|
||||||
agg_completion_tokens = 0
|
agg_completion_tokens = 0
|
||||||
aggregator_model = settings.LLM_MODEL # 占位,无实际 LLM 调用
|
aggregator_model = model or settings.LLM_MODEL # 占位,无实际 LLM 调用
|
||||||
|
|
||||||
latency_ms = int((time.perf_counter() - start) * 1000)
|
latency_ms = int((time.perf_counter() - start) * 1000)
|
||||||
|
|
||||||
@@ -347,7 +366,7 @@ async def predict_match_multi(
|
|||||||
"预测完成 match=%s mode=%s status=%s pred=%s:%s (%s) latency=%sms, experts=%d/%d, prediction_id=%s",
|
"预测完成 match=%s mode=%s status=%s pred=%s:%s (%s) latency=%sms, experts=%d/%d, prediction_id=%s",
|
||||||
match_id, "multi", pred_status,
|
match_id, "multi", pred_status,
|
||||||
pred.pred_home_goals, pred.pred_away_goals, pred.pred_1x2,
|
pred.pred_home_goals, pred.pred_away_goals, pred.pred_1x2,
|
||||||
latency_ms, ok_reports, len(reports), pred.id,
|
latency_ms, len(ok_reports), len(reports), pred.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
return MultiPredictResult(
|
return MultiPredictResult(
|
||||||
|
|||||||
+36
-5
@@ -10,7 +10,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
@@ -79,6 +79,35 @@ class BacktestSummary:
|
|||||||
results: list[BacktestMatchResult] = field(default_factory=list)
|
results: list[BacktestMatchResult] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date_bound(value, *, end_of_day: bool) -> datetime | None:
|
||||||
|
"""把日期入参解析成可与 timestamptz 列比较的 aware datetime。
|
||||||
|
|
||||||
|
支持 "YYYY-MM-DD"、完整 ISO 串(可带偏移)以及 datetime 对象;None 原样返回。
|
||||||
|
裸日期按 UTC 锚定 —— Match.match_date 是 timestamptz,naive datetime 与之
|
||||||
|
比较会因时区不同而偏移;start 取当天 00:00,end 取当天 23:59:59.999999
|
||||||
|
(闭区间,否则最后一天会被静默排除)。
|
||||||
|
|
||||||
|
解析失败抛 ValueError(不静默吞掉):fromisoformat 对非法输入统一抛 ValueError,
|
||||||
|
这里包一层以带上原始值,便于定位是哪个参数写错了。
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
dt = value
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(str(value))
|
||||||
|
except ValueError as e:
|
||||||
|
raise ValueError(f"无法解析日期: {value!r}(应为 YYYY-MM-DD 或 ISO 格式)") from e
|
||||||
|
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
# 闭区间上界:日期串解析出来是 00:00,取当天末刻才能让最后一天参与回测
|
||||||
|
if end_of_day:
|
||||||
|
dt = dt.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||||
|
return dt
|
||||||
|
|
||||||
|
|
||||||
async def _get_historical_matches(
|
async def _get_historical_matches(
|
||||||
db,
|
db,
|
||||||
*,
|
*,
|
||||||
@@ -106,10 +135,12 @@ async def _get_historical_matches(
|
|||||||
)
|
)
|
||||||
if league_id is not None:
|
if league_id is not None:
|
||||||
stmt = stmt.where(Match.league_id == league_id)
|
stmt = stmt.where(Match.league_id == league_id)
|
||||||
if date_from:
|
dt_from = _parse_date_bound(date_from, end_of_day=False)
|
||||||
stmt = stmt.where(Match.match_date >= date_from)
|
if dt_from is not None:
|
||||||
if date_to:
|
stmt = stmt.where(Match.match_date >= dt_from)
|
||||||
stmt = stmt.where(Match.match_date <= date_to)
|
dt_to = _parse_date_bound(date_to, end_of_day=True)
|
||||||
|
if dt_to is not None:
|
||||||
|
stmt = stmt.where(Match.match_date <= dt_to)
|
||||||
|
|
||||||
stmt = stmt.order_by(Match.match_date.desc()).limit(limit)
|
stmt = stmt.order_by(Match.match_date.desc()).limit(limit)
|
||||||
result = await db.execute(stmt)
|
result = await db.execute(stmt)
|
||||||
|
|||||||
+57
-57
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
架构:
|
架构:
|
||||||
- match_header: 比赛基础信息(对阵双方/联赛/时间)
|
- match_header: 比赛基础信息(对阵双方/联赛/时间)
|
||||||
- 切片函数: 每个领域 agent 一个数据切片(h2h / form / standings / injuries / xg)
|
- 切片函数: 每个领域 agent 一个数据切片(h2h / form / standings / stats)
|
||||||
- build_context: 单 agent 路径,拼接全部切片(行为与旧版一致)
|
- build_context: 单 agent 路径,拼接全部切片(行为与旧版一致)
|
||||||
|
|
||||||
multi-agent 路径由 agents/orchestrator.py 调用切片函数,每个专家只拿自己的切片。
|
multi-agent 路径由 agents/orchestrator.py 调用切片函数,每个专家只拿自己的切片。
|
||||||
@@ -81,7 +81,7 @@ class MatchContext:
|
|||||||
match_id: int
|
match_id: int
|
||||||
text: str
|
text: str
|
||||||
has_stats: bool
|
has_stats: bool
|
||||||
has_injuries: bool
|
has_standings: bool
|
||||||
match_dt: object | None = None # 比赛时间(回测防泄漏 + 快照用)
|
match_dt: object | None = None # 比赛时间(回测防泄漏 + 快照用)
|
||||||
cutoff: object | None = None # 实际使用的数据截止时间(用于落库记录)
|
cutoff: object | None = None # 实际使用的数据截止时间(用于落库记录)
|
||||||
|
|
||||||
@@ -337,72 +337,72 @@ async def home_away_slice(header: MatchHeader, *, limit: int = 10, before=None,
|
|||||||
return SliceResult(text="\n".join(lines), has_data=n_total > 0, n_records=n_total)
|
return SliceResult(text="\n".join(lines), has_data=n_total > 0, n_records=n_total)
|
||||||
|
|
||||||
|
|
||||||
async def injuries_slice(header: MatchHeader, *, before=None, db: AsyncSession | None = None) -> SliceResult:
|
async def standings_slice(header: MatchHeader, *, before=None, db: AsyncSession | None = None) -> SliceResult:
|
||||||
"""D - 阵容完整性切片: 伤停与停赛名单,评估战力缺失程度。
|
"""D - 联赛排名切片: 两队积分榜位置、积分、近期走势(form)、分区,评估整体实力差距。
|
||||||
|
|
||||||
before=cutoff: 只使用 cutoff 之前已采集的伤停数据,防回测泄漏。
|
before 参数保留与其他切片一致的签名(积分榜是最新快照,无历史版本,不受 cutoff 影响)。
|
||||||
db: 可选共享 session(见模块 docstring)。
|
db: 可选共享 session(见模块 docstring)。
|
||||||
|
|
||||||
语义区分:
|
语义区分:
|
||||||
- 查询成功 + 空结果 → has_data=True(明确知道「无人伤停」)
|
- 两队都有积分榜行 → has_data=True(明确的排名信息)
|
||||||
- 源未配置 / 查询失败 → has_data=False(无法判断,跳过 LLM)
|
- 任一队缺失 → has_data=False(升班马/杯赛无榜,信息不完整时明确声明)
|
||||||
"""
|
"""
|
||||||
from src.data.injuries import get_injuries_for_match, InjuryQueryResult
|
from src.db.models import League, Standing
|
||||||
|
|
||||||
cutoff = before or header.match_dt
|
|
||||||
if db is not None:
|
if db is not None:
|
||||||
home_result = await get_injuries_for_match(db, header.home_team_id, cutoff, as_of=cutoff)
|
league = (await db.execute(select(League).where(League.id == header.league_id))).scalar_one_or_none()
|
||||||
away_result = await get_injuries_for_match(db, header.away_team_id, cutoff, as_of=cutoff)
|
rows = (
|
||||||
|
(
|
||||||
|
await db.execute(
|
||||||
|
select(Standing)
|
||||||
|
.options(selectinload(Standing.team))
|
||||||
|
.where(Standing.league_id == header.league_id)
|
||||||
|
.order_by(Standing.position.asc())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
if league
|
||||||
|
else []
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
async with AsyncSessionLocal() as new_db:
|
async with AsyncSessionLocal() as new_db:
|
||||||
home_result = await get_injuries_for_match(new_db, header.home_team_id, cutoff, as_of=cutoff)
|
return await standings_slice(header, before=before, db=new_db)
|
||||||
away_result = await get_injuries_for_match(new_db, header.away_team_id, cutoff, as_of=cutoff)
|
|
||||||
|
|
||||||
# 判断是否有有效查询结果
|
lines = [f"── 联赛排名({header.league_name} 共 {len(rows)} 队) ──"]
|
||||||
# 两队都成功查询(即使为空) → has_data=True
|
|
||||||
# 任一查询失败或源未配置 → has_data=False
|
|
||||||
both_succeeded = (
|
|
||||||
home_result.query_status == "success"
|
|
||||||
and away_result.query_status == "success"
|
|
||||||
)
|
|
||||||
any_configured = (
|
|
||||||
home_result.query_status != "source_not_configured"
|
|
||||||
or away_result.query_status != "source_not_configured"
|
|
||||||
)
|
|
||||||
|
|
||||||
lines = ["── 阵容完整性 ──"]
|
|
||||||
n_records = 0
|
n_records = 0
|
||||||
|
|
||||||
for label, result in (("主队", home_result), ("客队", away_result)):
|
def _fmt(row) -> str:
|
||||||
if result.query_status == "source_not_configured":
|
zg = f" xG差 {row.xgd:+.1f}" if row.xg_for is not None and row.xg_against is not None and row.goal_diff is not None else ""
|
||||||
lines.append(f" {label}: 伤停源未配置")
|
form = f" 近5场 {row.form}" if row.form else ""
|
||||||
elif result.query_status == "query_error":
|
zone = f" [{row.zone}]" if row.zone else ""
|
||||||
lines.append(f" {label}: 查询异常")
|
return (
|
||||||
elif result.query_status == "no_local_data":
|
f" 第 {row.position} 名: {row.points} 分 / {row.played} 场 "
|
||||||
# API Key 已配置但本地无伤停记录
|
f"({row.won}胜{row.drawn}平{row.lost}负, 进{row.goals_for}失{row.goals_against} 净胜{row.goal_diff:+d}"
|
||||||
lines.append(f" {label}: 本地尚无伤停数据,请先采集")
|
f"{zg}){form}{zone}"
|
||||||
elif result.records:
|
)
|
||||||
n_records += len(result.records)
|
|
||||||
lines.append(f" {label}伤停({len(result.records)}人):")
|
for label, team_id in (("主队", header.home_team_id), ("客队", header.away_team_id)):
|
||||||
for inj in result.records[:8]:
|
row = next((r for r in rows if r.team_id == team_id), None)
|
||||||
reason = inj.reason or inj.injury_type or "未知"
|
if row is None:
|
||||||
lines.append(f" - {inj.player_name}: {reason}")
|
lines.append(f" {label}: 暂无积分榜数据(可能杯赛/赛季未开始)")
|
||||||
if len(result.records) > 8:
|
|
||||||
lines.append(f" ...及其他 {len(result.records) - 8} 人")
|
|
||||||
else:
|
else:
|
||||||
# success + 空列表 → 明确无伤停
|
n_records += 1
|
||||||
lines.append(f" {label}: 当前无伤停记录")
|
lines.append(f" {label} {header.home_name if label == '主队' else header.away_name}:")
|
||||||
|
lines.append(_fmt(row))
|
||||||
|
|
||||||
# 决定 has_data:
|
# 两队排名对比摘要
|
||||||
# - 两队都成功查询(即使为空) → True(明确知道名单)
|
home_row = next((r for r in rows if r.team_id == header.home_team_id), None)
|
||||||
# - 源未配置且无数据 → False
|
away_row = next((r for r in rows if r.team_id == header.away_team_id), None)
|
||||||
has_data = both_succeeded or (any_configured and n_records > 0)
|
if home_row and away_row:
|
||||||
|
diff = home_row.position - away_row.position # 正数=主队排名更靠前(名次更小)
|
||||||
|
lead = f"主队排名高 {diff} 位" if diff > 0 else (f"客队排名高 {-diff} 位" if diff < 0 else "两队同排名结构")
|
||||||
|
pts_diff = home_row.points - away_row.points
|
||||||
|
lines.append(f" 排名对比: {lead}, 分差 {pts_diff:+d}")
|
||||||
|
|
||||||
if not has_data:
|
# has_data: 两队都有行才算完整;只有一队时仍有价值,但标记不完整
|
||||||
# 保留详细状态文案(伤停源未配置/查询异常),而非通用「无数据」
|
has_data = n_records >= 1
|
||||||
return SliceResult(text="\n".join(lines), has_data=False, n_records=0)
|
return SliceResult(text="\n".join(lines), has_data=has_data, n_records=n_records)
|
||||||
|
|
||||||
return SliceResult(text="\n".join(lines), has_data=True, n_records=n_records)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -412,7 +412,7 @@ async def injuries_slice(header: MatchHeader, *, before=None, db: AsyncSession |
|
|||||||
async def build_context(match_id: int, *, form_last: int = 5, h2h_last: int = 5, backtest: bool = False, cutoff_at=None) -> MatchContext:
|
async def build_context(match_id: int, *, form_last: int = 5, h2h_last: int = 5, backtest: bool = False, cutoff_at=None) -> MatchContext:
|
||||||
"""单 agent 路径的完整上下文: 拼接全部切片(before=cutoff,防未来信息)。
|
"""单 agent 路径的完整上下文: 拼接全部切片(before=cutoff,防未来信息)。
|
||||||
|
|
||||||
has_stats / has_injuries 直接取切片显式声明的 has_data,
|
has_stats / has_standings 直接取切片显式声明的 has_data,
|
||||||
不再靠文案子串匹配(见审查报告 P2-1)。
|
不再靠文案子串匹配(见审查报告 P2-1)。
|
||||||
|
|
||||||
P2-6: backtest=True 时 cutoff = match_date - 1天,确保只用赛前数据。
|
P2-6: backtest=True 时 cutoff = match_date - 1天,确保只用赛前数据。
|
||||||
@@ -448,14 +448,14 @@ async def build_context(match_id: int, *, form_last: int = 5, h2h_last: int = 5,
|
|||||||
parts.append(home_away_res.text)
|
parts.append(home_away_res.text)
|
||||||
parts.append("")
|
parts.append("")
|
||||||
|
|
||||||
injuries_res = await injuries_slice(header, before=cutoff, db=db)
|
standings_res = await standings_slice(header, before=cutoff, db=db)
|
||||||
parts.append(injuries_res.text)
|
parts.append(standings_res.text)
|
||||||
|
|
||||||
return MatchContext(
|
return MatchContext(
|
||||||
match_id=match_id,
|
match_id=match_id,
|
||||||
text="\n".join(parts),
|
text="\n".join(parts),
|
||||||
has_stats=form_res.has_data or stats_res.has_data,
|
has_stats=form_res.has_data or stats_res.has_data,
|
||||||
has_injuries=injuries_res.has_data,
|
has_standings=standings_res.has_data,
|
||||||
match_dt=header.match_dt,
|
match_dt=header.match_dt,
|
||||||
cutoff=cutoff,
|
cutoff=cutoff,
|
||||||
)
|
)
|
||||||
|
|||||||
+2
-1
@@ -187,13 +187,14 @@ async def predict_match(
|
|||||||
)
|
)
|
||||||
from src.llm.agents.orchestrator import predict_match_multi
|
from src.llm.agents.orchestrator import predict_match_multi
|
||||||
|
|
||||||
# 回测参数完整传递到 multi-agent 路径
|
# 回测参数 + 模型覆盖完整传递到 multi-agent 路径
|
||||||
return await predict_match_multi(
|
return await predict_match_multi(
|
||||||
match_id,
|
match_id,
|
||||||
provider=provider,
|
provider=provider,
|
||||||
version=(prompt_version or "v1").removeprefix("multi_"),
|
version=(prompt_version or "v1").removeprefix("multi_"),
|
||||||
backtest=backtest,
|
backtest=backtest,
|
||||||
cutoff_at=cutoff_at,
|
cutoff_at=cutoff_at,
|
||||||
|
model=model,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
你是足球阵容完整性分析专家。分析以下两队的伤停与停赛信息,评估战力缺失程度。
|
|
||||||
|
|
||||||
{{context}}
|
|
||||||
|
|
||||||
分析要点:
|
|
||||||
- 核心球员缺阵影响(射手/组织核心/主力门将/后防中坚)
|
|
||||||
- 缺阵人数与位置分布(前场/中场/后场)
|
|
||||||
- 替补深度:缺阵是否有人可替
|
|
||||||
- 无数据时如实标注 data_sufficiency=none,不猜测
|
|
||||||
- 综合判断:哪支球队战力受损更严重
|
|
||||||
|
|
||||||
严格按此 JSON 输出,不要其他内容:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"data_sufficiency": "high|medium|low|none",
|
|
||||||
"analysis": "<150 字内分析,量化战力缺失程度>",
|
|
||||||
"home_edge": <-1.0 到 1.0, 正数=客队伤停更严重(利主队)>,
|
|
||||||
"confidence": <0.0-1.0>,
|
|
||||||
"key_evidence": ["<证据1>", "<证据2>"]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
你是足球联赛排名分析专家。分析以下两队在联赛积分榜上的位置、积分与近期走势,评估整体实力差距。
|
||||||
|
|
||||||
|
{{context}}
|
||||||
|
|
||||||
|
分析要点:
|
||||||
|
- 排名与分差:排名差距反映的整体实力层级,是否属于同档球队
|
||||||
|
- 攻防质量:进球/失球/净胜球与 xG 差(xgd)是否匹配,有无虚高或低估
|
||||||
|
- 赛程消耗:已赛场次差异(少赛场次可能反映赛程推迟或杯赛分心)
|
||||||
|
- 近期走势:form 串(如 WWDLL)显示的状态趋势,与排名是否一致
|
||||||
|
- 分区含义:争冠/欧战区/保级区的处境对比赛动机的影响
|
||||||
|
- 无数据时如实标注 data_sufficiency=none,不猜测
|
||||||
|
- 综合判断:哪支球队整体实力与动机占优
|
||||||
|
|
||||||
|
严格按此 JSON 输出,不要其他内容:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data_sufficiency": "high|medium|low|none",
|
||||||
|
"analysis": "<150 字内分析,量化两队实力差距>",
|
||||||
|
"home_edge": <-1.0 到 1.0, 正数=主队实力占优>,
|
||||||
|
"confidence": <0.0-1.0>,
|
||||||
|
"key_evidence": ["<证据1>", "<证据2>"]
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -7,12 +7,18 @@
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.db.models import Prediction
|
from src.db.models import Prediction
|
||||||
|
|
||||||
|
# 仓库根目录下的 alembic 迁移目录 —— 相对本测试文件解析,
|
||||||
|
# 避免硬编码某台机器/CI 上的绝对路径(见 tests/test_regressions.py 的 _read 约定)。
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
MIGRATION_PATH = REPO_ROOT / "alembic" / "versions" / "0014_predictions_agent_weights.py"
|
||||||
|
|
||||||
|
|
||||||
class TestAgentWeightsColumn:
|
class TestAgentWeightsColumn:
|
||||||
"""验证 predictions 表有 agent_weights 列。"""
|
"""验证 predictions 表有 agent_weights 列。"""
|
||||||
@@ -38,14 +44,10 @@ class TestMigration:
|
|||||||
"""验证迁移文件存在且内容正确。"""
|
"""验证迁移文件存在且内容正确。"""
|
||||||
|
|
||||||
def test_migration_exists(self):
|
def test_migration_exists(self):
|
||||||
import os
|
assert MIGRATION_PATH.is_file(), f"迁移文件不存在: {MIGRATION_PATH}"
|
||||||
|
|
||||||
path = "/.octop/workspaces/CA7PFH/Profeto/alembic/versions/0014_predictions_agent_weights.py"
|
|
||||||
assert os.path.exists(path)
|
|
||||||
|
|
||||||
def test_migration_content(self):
|
def test_migration_content(self):
|
||||||
path = "/.octop/workspaces/CA7PFH/Profeto/alembic/versions/0014_predictions_agent_weights.py"
|
content = MIGRATION_PATH.read_text(encoding="utf-8")
|
||||||
content = open(path).read()
|
|
||||||
|
|
||||||
assert "agent_weights" in content
|
assert "agent_weights" in content
|
||||||
assert "upgrade" in content
|
assert "upgrade" in content
|
||||||
@@ -76,13 +78,13 @@ class TestOrchestratorWritesAgentWeights:
|
|||||||
AgentReport(agent="form", status="ok", analysis="good"),
|
AgentReport(agent="form", status="ok", analysis="good"),
|
||||||
AgentReport(agent="stats", status="error", analysis="failed"),
|
AgentReport(agent="stats", status="error", analysis="failed"),
|
||||||
AgentReport(agent="home_away", status="ok", analysis="good"),
|
AgentReport(agent="home_away", status="ok", analysis="good"),
|
||||||
AgentReport(agent="injuries", status="no_data", analysis="无数据"),
|
AgentReport(agent="standings", status="no_data", analysis="无数据"),
|
||||||
AgentReport(agent="h2h", status="error", analysis="failed"),
|
AgentReport(agent="h2h", status="error", analysis="failed"),
|
||||||
]
|
]
|
||||||
|
|
||||||
captured_values = {}
|
captured_values = {}
|
||||||
|
|
||||||
async def mock_specialists(h, *, version, before):
|
async def mock_specialists(h, *, version, before, model_override=None):
|
||||||
return reports
|
return reports
|
||||||
|
|
||||||
async def mock_provider(aid, **kw):
|
async def mock_provider(aid, **kw):
|
||||||
|
|||||||
+11
-7
@@ -22,7 +22,7 @@ class TestNoDataGate:
|
|||||||
|
|
||||||
def test_stub_no_data_report(self):
|
def test_stub_no_data_report(self):
|
||||||
from src.llm.agents.base import _stub_no_data
|
from src.llm.agents.base import _stub_no_data
|
||||||
r = _stub_no_data("injuries")
|
r = _stub_no_data("standings")
|
||||||
assert r.status == "no_data"
|
assert r.status == "no_data"
|
||||||
assert r.data_sufficiency == "none"
|
assert r.data_sufficiency == "none"
|
||||||
assert r.home_edge is None
|
assert r.home_edge is None
|
||||||
@@ -31,7 +31,7 @@ class TestNoDataGate:
|
|||||||
class TestPromptLoading:
|
class TestPromptLoading:
|
||||||
"""agent prompt 模板加载。"""
|
"""agent prompt 模板加载。"""
|
||||||
|
|
||||||
@pytest.mark.parametrize("name", ["form", "stats", "home_away", "injuries", "h2h", "aggregator"])
|
@pytest.mark.parametrize("name", ["form", "stats", "home_away", "standings", "h2h", "aggregator"])
|
||||||
def test_all_prompts_exist(self, name):
|
def test_all_prompts_exist(self, name):
|
||||||
tpl = load_agent_prompt(name, "v1")
|
tpl = load_agent_prompt(name, "v1")
|
||||||
assert "{{context}}" in tpl or "{{agent_reports}}" in tpl
|
assert "{{context}}" in tpl or "{{agent_reports}}" in tpl
|
||||||
@@ -126,7 +126,7 @@ class TestRunAgent:
|
|||||||
async def empty_slice(header, before=None):
|
async def empty_slice(header, before=None):
|
||||||
return "── 伤停 ──\n 无数据"
|
return "── 伤停 ──\n 无数据"
|
||||||
|
|
||||||
spec = AgentSpec(name="injuries", system_prompt="s", slice_fn=empty_slice)
|
spec = AgentSpec(name="standings", system_prompt="s", slice_fn=empty_slice)
|
||||||
header = self._make_header()
|
header = self._make_header()
|
||||||
|
|
||||||
class ExplodingProvider:
|
class ExplodingProvider:
|
||||||
@@ -196,18 +196,22 @@ class TestOrchestratorAggregation:
|
|||||||
"""终裁输入拼装逻辑。"""
|
"""终裁输入拼装逻辑。"""
|
||||||
|
|
||||||
def test_reports_to_json(self):
|
def test_reports_to_json(self):
|
||||||
from src.llm.agents.orchestrator import _reports_to_json
|
from src.llm.agents.orchestrator import _reports_to_json, AGENT_LABELS_ZH
|
||||||
import json
|
import json
|
||||||
|
|
||||||
reports = [
|
reports = [
|
||||||
AgentReport(agent="h2h", status="ok", home_edge=0.5, subjective_confidence=0.8, analysis="a"),
|
AgentReport(agent="h2h", status="ok", home_edge=0.5, subjective_confidence=0.8, analysis="a"),
|
||||||
AgentReport(agent="injuries", status="no_data", data_sufficiency="none"),
|
AgentReport(agent="standings", status="no_data", data_sufficiency="none"),
|
||||||
]
|
]
|
||||||
text = _reports_to_json(reports)
|
text = _reports_to_json(reports)
|
||||||
data = json.loads(text)
|
data = json.loads(text)
|
||||||
assert len(data) == 2
|
assert len(data) == 2
|
||||||
assert data[0]["agent"] == "h2h"
|
# 契约: agent 字段序列化为中文专家全名,引导终裁用统一称呼引用
|
||||||
|
# (见 orchestrator.AGENT_LABELS_ZH 与 _reports_to_json 的 docstring)
|
||||||
|
assert data[0]["agent"] == AGENT_LABELS_ZH["h2h"] == "历史交锋分析专家"
|
||||||
assert data[1]["status"] == "no_data"
|
assert data[1]["status"] == "no_data"
|
||||||
|
# 两个 agent 都应被映射,不留英文原键
|
||||||
|
assert data[1]["agent"] == AGENT_LABELS_ZH["standings"]
|
||||||
|
|
||||||
def test_aggregator_prompt_renders(self):
|
def test_aggregator_prompt_renders(self):
|
||||||
"""终裁 prompt 模板两占位符都能渲染。"""
|
"""终裁 prompt 模板两占位符都能渲染。"""
|
||||||
@@ -270,7 +274,7 @@ class TestAgentWeightsValidation:
|
|||||||
|
|
||||||
w = validate_agent_weights({"form": 0.4, "h2h": 0.4, "bogus": 0.2, "stats": 0.4})
|
w = validate_agent_weights({"form": 0.4, "h2h": 0.4, "bogus": 0.2, "stats": 0.4})
|
||||||
assert "bogus" not in w
|
assert "bogus" not in w
|
||||||
assert set(w) <= {"form", "stats", "home_away", "injuries", "h2h"}
|
assert set(w) <= {"form", "stats", "home_away", "standings", "h2h"}
|
||||||
|
|
||||||
def test_out_of_range_clamped(self):
|
def test_out_of_range_clamped(self):
|
||||||
from src.llm.validation import validate_agent_weights
|
from src.llm.validation import validate_agent_weights
|
||||||
|
|||||||
@@ -5,8 +5,9 @@
|
|||||||
2. _is_stats_available: available_at is None + cutoff is None(实盘) → 可用(兼容旧数据)
|
2. _is_stats_available: available_at is None + cutoff is None(实盘) → 可用(兼容旧数据)
|
||||||
3. _is_stats_available: available_at > cutoff → 不可用
|
3. _is_stats_available: available_at > cutoff → 不可用
|
||||||
4. _is_stats_available: available_at <= cutoff → 可用
|
4. _is_stats_available: available_at <= cutoff → 可用
|
||||||
5. 写入策略: available_at = match_date + 2h 缓冲
|
5. 写入策略: bzzoiro 写入 available_at 使用 match_date + 2h 缓冲
|
||||||
6. cutoff 在缓冲内时不可用(available_at > cutoff → False)
|
6. cutoff 在缓冲内时不可用(available_at > cutoff → False)
|
||||||
|
7. stats 回填(bzzoiro event stats)也使用 2h 缓冲
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -73,37 +74,26 @@ class TestIsStatsAvailable:
|
|||||||
|
|
||||||
|
|
||||||
class TestWriteBufferStrategy:
|
class TestWriteBufferStrategy:
|
||||||
"""验证 bzzoiro/understat 写入 available_at 使用 match_date + 2h 缓冲。"""
|
"""验证 bzzoiro(events + stats 回填)写入 available_at 使用 match_date + 2h 缓冲。"""
|
||||||
|
|
||||||
def test_bzzoirot_new_match_available_at_is_two_hours_after_kickoff(self):
|
def test_bzzoirot_new_match_available_at_is_two_hours_after_kickoff(self):
|
||||||
"""bzzoiro 新建比赛时 available_at 应为开球 + 2 小时。"""
|
"""bzzoiro 新建比赛(stats 回填创建 MatchStats)时 available_at 应为开球 + 2 小时。"""
|
||||||
import inspect
|
import inspect
|
||||||
from src.data import bzzoiro
|
from src.data import bzzoiro
|
||||||
|
|
||||||
source = inspect.getsource(bzzoiro)
|
source = inspect.getsource(bzzoiro)
|
||||||
# 验证:使用 timedelta(hours=2) 作为缓冲
|
|
||||||
assert 'timedelta(hours=2)' in source, \
|
assert 'timedelta(hours=2)' in source, \
|
||||||
"bzzoiro 应使用 match_date + timedelta(hours=2) 作为 available_at"
|
"bzzoiro 应使用 match_date + timedelta(hours=2) 作为 available_at"
|
||||||
|
|
||||||
def test_bzzoirot_existing_match_uses_two_hour_buffer(self):
|
def test_bzzoirot_multiple_writes_use_two_hour_buffer(self):
|
||||||
"""bzzoiro 更新已有比赛时也应使用 2 小时缓冲。"""
|
"""bzzoiro 多处写入(创建/更新)都应使用 2 小时缓冲。"""
|
||||||
import inspect
|
import inspect
|
||||||
from src.data import bzzoiro
|
from src.data import bzzoiro
|
||||||
|
|
||||||
source = inspect.getsource(bzzoiro)
|
source = inspect.getsource(bzzoiro)
|
||||||
# 两处写入都应使用 timedelta(hours=2)
|
|
||||||
count = source.count('timedelta(hours=2)')
|
count = source.count('timedelta(hours=2)')
|
||||||
assert count >= 2, f"期望至少 2 处 timedelta(hours=2),实际 {count} 处"
|
assert count >= 2, f"期望至少 2 处 timedelta(hours=2),实际 {count} 处"
|
||||||
|
|
||||||
def test_understat_uses_two_hour_buffer(self):
|
|
||||||
"""understat 回填 xG 时也应使用 2 小时缓冲。"""
|
|
||||||
import inspect
|
|
||||||
from src.data import understat
|
|
||||||
|
|
||||||
source = inspect.getsource(understat)
|
|
||||||
assert 'timedelta(hours=2)' in source, \
|
|
||||||
"understat 应使用 match_date + timedelta(hours=2) 作为 available_at"
|
|
||||||
|
|
||||||
def test_cutoff_within_buffer_makes_stats_unavailable(self):
|
def test_cutoff_within_buffer_makes_stats_unavailable(self):
|
||||||
"""cutoff 在 2 小时缓冲内时,统计学不可用(回测防泄漏)。
|
"""cutoff 在 2 小时缓冲内时,统计学不可用(回测防泄漏)。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
"""回归测试:锁定 bzzoiro events 管线被删除 + 注册表静默失效的缺陷不再复发。
|
||||||
|
|
||||||
|
背景(真实数据丢失事故):
|
||||||
|
重构提交 6a49940 删除了 src/data/bzzoiro.py 中的 fetch_bzzoiro_events 与
|
||||||
|
BzzoiroSource,但 src/data/sources.py 的模块级预热把 ImportError 用
|
||||||
|
`try/except Exception: pass` 吞掉了。后果:
|
||||||
|
- _SOURCES 注册表恒为空
|
||||||
|
- get_source("bzzoiro") 恒抛 ValueError("未知数据源: bzzoiro")
|
||||||
|
- src/api/routes/ingest.py 与 schedules.py 在运行时全线失效,
|
||||||
|
且日志中看不到任何导入错误的痕迹 —— 这才是它长期漏网的原因。
|
||||||
|
|
||||||
|
本测试用静态结构断言 + 真实导入来锁死这两点,不 mock 网络:
|
||||||
|
- 注册表必须非空(直接守卫「静默吞异常」回归)
|
||||||
|
- get_source("bzzoiro") 必须返回真实实例
|
||||||
|
- ingest() 的签名必须保持 caller 依赖的 keyword-only 参数
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.data.bzzoiro import BzzoiroSource, fetch_bzzoiro_events
|
||||||
|
from src.data.sources import get_source, list_sources
|
||||||
|
|
||||||
|
SRC = Path(__file__).resolve().parent.parent / "src"
|
||||||
|
|
||||||
|
|
||||||
|
class TestSourceRegistry:
|
||||||
|
"""P0: 注册表必须真的装载到 bzzoiro,而不是静默为空。"""
|
||||||
|
|
||||||
|
def test_registry_is_not_empty(self):
|
||||||
|
"""注册表恒非空 —— 直接守卫 `try/except: pass` 静默吞 ImportError。"""
|
||||||
|
assert list_sources(), (
|
||||||
|
"数据源注册表为空 —— _load_sources() 的导入失败了,"
|
||||||
|
"且(修复前)异常被静默吞掉。任何导入错误都必须被 logger.exception 记录。"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_get_source_returns_bzzoiro(self):
|
||||||
|
"""get_source('bzzoiro') 必须返回实例,而不是抛 ValueError。"""
|
||||||
|
source = get_source("bzzoiro")
|
||||||
|
assert source.name == "bzzoiro"
|
||||||
|
|
||||||
|
def test_list_sources_contains_bzzoiro(self):
|
||||||
|
assert "bzzoiro" in list_sources()
|
||||||
|
|
||||||
|
def test_register_decorator_still_exports_the_class(self):
|
||||||
|
"""@register 必须仍然返回原类(不能再被改成返回实例)。"""
|
||||||
|
assert isinstance(BzzoiroSource, type), "@register 不应把类替换成实例"
|
||||||
|
|
||||||
|
|
||||||
|
class TestIngestContract:
|
||||||
|
"""caller 依赖 ingest() 的签名,routes 传的就是这些参数。"""
|
||||||
|
|
||||||
|
def test_ingest_exists_and_is_async(self):
|
||||||
|
assert hasattr(BzzoiroSource, "ingest"), "BzzoiroSource.ingest 丢失(events 管线被删)"
|
||||||
|
assert inspect.iscoroutinefunction(BzzoiroSource.ingest), "ingest 必须是 async"
|
||||||
|
|
||||||
|
def test_ingest_accepts_caller_keyword_args(self):
|
||||||
|
"""ingest.py:65 与 schedules.py:41 依赖的 keyword-only 参数必须齐全。"""
|
||||||
|
params = inspect.signature(BzzoiroSource.ingest).parameters
|
||||||
|
for name in ("leagues", "date_from", "date_to", "status"):
|
||||||
|
assert name in params, f"ingest() 缺少 keyword 参数 {name!r} —— caller 会 TypeError"
|
||||||
|
# leagues 必须 keyword-only(src/api/routes/ingest.py 用 leagues=[code] 传)
|
||||||
|
assert params["leagues"].kind is inspect.Parameter.KEYWORD_ONLY
|
||||||
|
# 默认值契约:schedules.py 只传 leagues + status
|
||||||
|
assert params["date_from"].default is None
|
||||||
|
assert params["date_to"].default is None
|
||||||
|
assert params["status"].default == "finished"
|
||||||
|
|
||||||
|
def test_fetch_bzzoiro_events_signature(self):
|
||||||
|
"""抓取函数也必须存在,且 leagues 抓取走 keyword-only 的 status/日期。"""
|
||||||
|
assert callable(fetch_bzzoiro_events)
|
||||||
|
params = inspect.signature(fetch_bzzoiro_events).parameters
|
||||||
|
assert "league_code" in params
|
||||||
|
for name in ("status", "date_from", "date_to"):
|
||||||
|
assert name in params, f"fetch_bzzoiro_events() 缺少 {name!r}"
|
||||||
|
|
||||||
|
|
||||||
|
class TestImportOrderSelfHeal:
|
||||||
|
"""导入次序不得影响注册表 —— 这是本缺陷的第二个隐藏面。
|
||||||
|
|
||||||
|
背景:src.api.app 先 `import src.data.sources`,其模块级预热在
|
||||||
|
`src.data.bzzoiro` 尚未初始化时发起,导入链在 sources.py 内成环,
|
||||||
|
首次导入必然失败(bzzoiro 仍在加载中)。修复前 `except Exception: pass`
|
||||||
|
把这次失败同时变得「无声」且「不可恢复」,注册表就永久空掉了。
|
||||||
|
|
||||||
|
这里用独立子进程验证真实导入次序,不 mock —— 因为该缺陷只在
|
||||||
|
真实的模块初始化时序下才成立,单元测试里的 mock 反而照不出来。
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _run(import_lines: list[str]) -> str:
|
||||||
|
"""在全新解释器中按指定次序导入,返回 get_source/list_sources 结果。"""
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
root = Path(__file__).resolve().parent.parent
|
||||||
|
code = (
|
||||||
|
"import sys; sys.path.insert(0, r'%s')\n" % root
|
||||||
|
+ "\n".join(import_lines)
|
||||||
|
+ "\nfrom src.data.sources import get_source, list_sources\n"
|
||||||
|
"print('RESULT', get_source('bzzoiro').name, list_sources())\n"
|
||||||
|
)
|
||||||
|
proc = subprocess.run(
|
||||||
|
[sys.executable, "-c", code],
|
||||||
|
capture_output=True, text=True, timeout=120, cwd=str(root),
|
||||||
|
)
|
||||||
|
assert proc.returncode == 0, (
|
||||||
|
f"导入次序 {import_lines} 下 get_source('bzzoiro') 失败:\n{proc.stderr[-1500:]}"
|
||||||
|
)
|
||||||
|
return proc.stdout.strip()
|
||||||
|
|
||||||
|
def test_sources_first_then_bzzoiro(self):
|
||||||
|
"""次序 B:sources 先导入(正常应用路径)。"""
|
||||||
|
out = self._run(["import src.data.sources"])
|
||||||
|
assert out == "RESULT bzzoiro ['bzzoiro']", out
|
||||||
|
|
||||||
|
def test_bzzoiro_first_then_sources(self):
|
||||||
|
"""次序 A:bzzoiro 先导入 —— 注册表必须仍然可用(自愈)。"""
|
||||||
|
out = self._run(["import src.data.bzzoiro"])
|
||||||
|
assert out == "RESULT bzzoiro ['bzzoiro']", (
|
||||||
|
out + " —— 注册表为空说明首次装载失败后没有再重试(静默失效回归)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unknown_name_still_raises(self):
|
||||||
|
"""真正未知的名字仍须抛 ValueError —— 修复不应放宽这一契约。"""
|
||||||
|
with pytest.raises(ValueError, match="未知数据源"):
|
||||||
|
get_source("no_such_source_xyz")
|
||||||
|
|
||||||
|
|
||||||
|
class TestNoSilentImportSwallow:
|
||||||
|
"""sources.py 不许再用 `except Exception: pass` 吞掉导入失败。"""
|
||||||
|
|
||||||
|
def test_load_sources_logs_failure(self):
|
||||||
|
src = (SRC / "data" / "sources.py").read_text(encoding="utf-8")
|
||||||
|
assert "logger" in src, "sources.py 缺少模块 logger,无法记录导入失败"
|
||||||
|
body = src[src.index("def _load_sources"):]
|
||||||
|
assert "logger.exception" in body, (
|
||||||
|
"_load_sources() 失败时未记日志 —— 静默吞异常会让注册表恒为空,"
|
||||||
|
"把 ImportError 伪装成「未知数据源」(P0 事故根因)"
|
||||||
|
)
|
||||||
|
assert "pass" not in body.split("def ")[0], "不得再用裸 pass 吞掉导入异常"
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -101,14 +101,16 @@ class TestH2HCurrentHomePerspective:
|
|||||||
home_name="曼城", away_name="诺维奇"),
|
home_name="曼城", away_name="诺维奇"),
|
||||||
]
|
]
|
||||||
import src.llm.context_builder as cb
|
import src.llm.context_builder as cb
|
||||||
orig = cb._get_h2h
|
|
||||||
cb._get_h2h = lambda db, h, a, before, **kw: matches
|
async def mock_get_h2h(db, h, a, before, **kw):
|
||||||
try:
|
# 真实契约是 async(见 context_builder.py 的 `h2h = await _get_h2h(...)`),
|
||||||
|
# 同步 lambda 会抛 TypeError: object list can't be used in 'await' expression。
|
||||||
|
return matches
|
||||||
|
|
||||||
|
with patch.object(cb, "_get_h2h", mock_get_h2h):
|
||||||
result = await h2h_slice(header, limit=8, before=None)
|
result = await h2h_slice(header, limit=8, before=None)
|
||||||
text = str(result)
|
text = str(result)
|
||||||
assert "2胜 0平 0负" in text, f"期望「2胜 0平 0负」,实际:\n{text}"
|
assert "2胜 0平 0负" in text, f"期望「2胜 0平 0负」,实际:\n{text}"
|
||||||
finally:
|
|
||||||
cb._get_h2h = orig
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_draw_counted_correctly(self):
|
async def test_draw_counted_correctly(self):
|
||||||
|
|||||||
@@ -1,166 +0,0 @@
|
|||||||
"""回归测试: injuries 入库 IntegrityError 后 inserted 计数准确。
|
|
||||||
|
|
||||||
验证:
|
|
||||||
1. flush 失败的批次不计入 inserted
|
|
||||||
2. 成功的批次正常计数
|
|
||||||
3. 总计数 = 成功批次记录数之和
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from unittest.mock import MagicMock, AsyncMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from sqlalchemy.exc import IntegrityError
|
|
||||||
|
|
||||||
from src.data.injuries import ingest_injuries
|
|
||||||
|
|
||||||
|
|
||||||
class FakeSession:
|
|
||||||
"""模拟 AsyncSession,记录 flush 调用和 begin_nested 使用。"""
|
|
||||||
|
|
||||||
def __init__(self, fail_on_flush_indices: set[int] | None = None):
|
|
||||||
self.flush_count = 0
|
|
||||||
self.nested_count = 0
|
|
||||||
self.added_records = []
|
|
||||||
self.committed_batches = []
|
|
||||||
self.fail_on = fail_on_flush_indices or set()
|
|
||||||
|
|
||||||
async def execute(self, stmt):
|
|
||||||
class Result:
|
|
||||||
def all(self_inner):
|
|
||||||
return []
|
|
||||||
def scalar_one_or_none(self_inner):
|
|
||||||
return None
|
|
||||||
return Result()
|
|
||||||
|
|
||||||
async def get(self, cls, id):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def add(self, obj):
|
|
||||||
self.added_records.append({"player_id": obj.player_id, "fixture_id": obj.fixture_id})
|
|
||||||
|
|
||||||
async def flush(self):
|
|
||||||
self.flush_count += 1
|
|
||||||
if self.flush_count in self.fail_on:
|
|
||||||
raise IntegrityError("mock duplicate", None, None)
|
|
||||||
|
|
||||||
def begin_nested(self):
|
|
||||||
class NestedCtx:
|
|
||||||
async def __aenter__(nested_self):
|
|
||||||
return nested_self
|
|
||||||
async def __aexit__(nested_self, exc_type, exc, tb):
|
|
||||||
return exc_type is not None
|
|
||||||
return NestedCtx()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_inserted_count_excludes_failed_batches():
|
|
||||||
"""flush 失败的批次不应计入 inserted。
|
|
||||||
|
|
||||||
场景:6 条记录,每批 2 条(BATCH_SIZE=2),第 2 批 flush 失败。
|
|
||||||
期望:inserted = 2(第 1 批成功) + 0(第 2 批失败) + 2(第 3 批成功) = 4
|
|
||||||
"""
|
|
||||||
session = FakeSession(fail_on_flush_indices={2}) # 第 2 次 flush 失败
|
|
||||||
|
|
||||||
# 构造 6 条待插入记录
|
|
||||||
pending = [
|
|
||||||
{"player_id": i, "player_name": f"Player{i}", "team_id": 1,
|
|
||||||
"fixture_id": 100 + i, "injury_type": "Hamstring",
|
|
||||||
"reason": "strain", "injury_date": None, "return_date": None}
|
|
||||||
for i in range(6)
|
|
||||||
]
|
|
||||||
|
|
||||||
# 临时覆盖 BATCH_SIZE 为 2
|
|
||||||
original = ingest_injuries.__globals__.get("BATCH_SIZE")
|
|
||||||
|
|
||||||
result = {"count": 0, "inserted": 0, "errors": []}
|
|
||||||
|
|
||||||
# 模拟核心逻辑(与 ingest_injuries 一致)
|
|
||||||
async def run():
|
|
||||||
BATCH_SIZE = 2 # 小批量便于测试
|
|
||||||
batch = []
|
|
||||||
|
|
||||||
async def _flush_batch():
|
|
||||||
if not batch:
|
|
||||||
return 0
|
|
||||||
count = len(batch)
|
|
||||||
async with session.begin_nested():
|
|
||||||
for obj in batch:
|
|
||||||
session.add(obj)
|
|
||||||
await db_flush()
|
|
||||||
batch.clear()
|
|
||||||
return count
|
|
||||||
|
|
||||||
async def db_flush():
|
|
||||||
session.flush_count += 1
|
|
||||||
if session.flush_count in session.fail_on:
|
|
||||||
raise IntegrityError("mock", None, None)
|
|
||||||
session.committed_batches.append(count)
|
|
||||||
|
|
||||||
for rec in pending:
|
|
||||||
batch.append(type("Injury", (), rec))
|
|
||||||
if len(batch) >= BATCH_SIZE:
|
|
||||||
try:
|
|
||||||
result["inserted"] += await _flush_batch()
|
|
||||||
except IntegrityError:
|
|
||||||
batch.clear()
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
result["inserted"] += await _flush_batch()
|
|
||||||
except IntegrityError:
|
|
||||||
batch.clear()
|
|
||||||
|
|
||||||
await run()
|
|
||||||
|
|
||||||
# 第 1 批(0,1)成功,第 2 批(2,3)失败,第 3 批(4,5)成功
|
|
||||||
assert result["inserted"] == 4, f"期望 inserted=4,实际 {result['inserted']}"
|
|
||||||
print(f"PASS: inserted={result['inserted']} (排除失败批次)")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_all_success_count_is_total(self):
|
|
||||||
"""全部成功时,inserted 应等于总记录数。"""
|
|
||||||
session = FakeSession() # 无失败
|
|
||||||
|
|
||||||
pending = [
|
|
||||||
{"player_id": i, "player_name": f"P{i}", "team_id": 1,
|
|
||||||
"fixture_id": 100 + i, "injury_type": None,
|
|
||||||
"reason": None, "injury_date": None, "return_date": None}
|
|
||||||
for i in range(6)
|
|
||||||
]
|
|
||||||
|
|
||||||
result = {"inserted": 0}
|
|
||||||
BATCH_SIZE = 2
|
|
||||||
batch = []
|
|
||||||
|
|
||||||
async def _flush_batch():
|
|
||||||
if not batch:
|
|
||||||
return 0
|
|
||||||
count = len(batch)
|
|
||||||
async with session.begin_nested():
|
|
||||||
for obj in batch:
|
|
||||||
session.add(obj)
|
|
||||||
await db_flush()
|
|
||||||
batch.clear()
|
|
||||||
return count
|
|
||||||
|
|
||||||
async def db_flush():
|
|
||||||
session.flush_count += 1
|
|
||||||
session.committed_batches.append(batch.copy())
|
|
||||||
|
|
||||||
for rec in pending:
|
|
||||||
batch.append(type("Injury", (), rec))
|
|
||||||
if len(batch) >= BATCH_SIZE:
|
|
||||||
result["inserted"] += await _flush_batch()
|
|
||||||
result["inserted"] += await _flush_batch()
|
|
||||||
|
|
||||||
assert result["inserted"] == 6, f"期望 6,实际 {result['inserted']}"
|
|
||||||
print(f"PASS: 全部成功 inserted={result['inserted']}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(test_inserted_count_excludes_failed_batches())
|
|
||||||
asyncio.run(test_all_success_count_is_total())
|
|
||||||
print("\n=== ALL TESTS PASSED ===")
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
"""回归测试: injuries IntegrityError 处理不再整批回滚。
|
|
||||||
|
|
||||||
模拟场景:连续插入多条伤停记录,中间一批触发 IntegrityError,
|
|
||||||
断言其它批次记录不会丢失。
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from unittest.mock import MagicMock, AsyncMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.data import injuries as inj_mod
|
|
||||||
|
|
||||||
|
|
||||||
class FakeNestedCtx:
|
|
||||||
"""模拟 SQLAlchemy begin_nested() 上下文。
|
|
||||||
|
|
||||||
__enter__:标记进入 savepoint
|
|
||||||
__exit__:如果有异常,模拟 ROLLBACK TO SAVEPOINT(不清空已 flush 的对象)
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, session):
|
|
||||||
self.session = session
|
|
||||||
self.rolled_back = False
|
|
||||||
|
|
||||||
async def __aenter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __aexit__(self, exc_type, exc, tb):
|
|
||||||
if exc_type is not None:
|
|
||||||
# ROLLBACK TO SAVEPOINT — 不清空 session 中已存在的对象
|
|
||||||
self.rolled_back = True
|
|
||||||
return True # suppress exception
|
|
||||||
|
|
||||||
|
|
||||||
class FakeSession:
|
|
||||||
"""模拟 AsyncSession,记录 flush 调用和 begin_nested 使用。"""
|
|
||||||
|
|
||||||
def __init__(self, fail_on_flush_indices: set[int] | None = None):
|
|
||||||
self.flush_count = 0
|
|
||||||
self.nested_count = 0
|
|
||||||
self.flushed_records: list[dict] = []
|
|
||||||
self.added_records: list[dict] = []
|
|
||||||
self.fail_on = fail_on_flush_indices or set()
|
|
||||||
|
|
||||||
async def execute(self, stmt):
|
|
||||||
class Result:
|
|
||||||
def all(self_inner):
|
|
||||||
return []
|
|
||||||
return Result()
|
|
||||||
|
|
||||||
async def get(self, cls, id):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def add(self, obj):
|
|
||||||
self.added_records.append(obj)
|
|
||||||
|
|
||||||
async def flush(self):
|
|
||||||
self.flush_count += 1
|
|
||||||
if self.flush_count in self.fail_on:
|
|
||||||
from sqlalchemy.exc import IntegrityError
|
|
||||||
raise IntegrityError("mock duplicate", None, None)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def _nested_ctx(self):
|
|
||||||
return FakeNestedCtx(self)
|
|
||||||
|
|
||||||
def begin_nested(self):
|
|
||||||
self.nested_count += 1
|
|
||||||
return self._nested_ctx
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_integrity_error_does_not_lose_other_batches():
|
|
||||||
"""核心测试:一批触发 IntegrityError,其它批次记录不丢失。
|
|
||||||
|
|
||||||
场景:3 批记录,第 2 批 flush 时 IntegrityError。
|
|
||||||
断言:第 1 批和第 3 批的记录仍存在于 flushed_records 中。
|
|
||||||
"""
|
|
||||||
session = FakeSession(fail_on_flush_indices={2}) # 第 2 次 flush 失败
|
|
||||||
|
|
||||||
# 构造 3 批记录,每批 2 条(BATCH_SIZE 用 2 方便测试)
|
|
||||||
pending = [
|
|
||||||
{"player_id": i, "player_name": f"Player{i}", "team_id": 1,
|
|
||||||
"fixture_id": 100 + i, "injury_type": "Hamstring",
|
|
||||||
"reason": "strain", "injury_date": None, "return_date": None}
|
|
||||||
for i in range(6)
|
|
||||||
]
|
|
||||||
|
|
||||||
# 临时覆盖 BATCH_SIZE
|
|
||||||
original_batch_size = 50
|
|
||||||
try:
|
|
||||||
inj_mod.ingest_injuries.__globals__['__dict__'] # no-op
|
|
||||||
|
|
||||||
# 手动模拟 ingest_injuries 的核心逻辑
|
|
||||||
batch = []
|
|
||||||
flushed_ids = []
|
|
||||||
errors = []
|
|
||||||
|
|
||||||
async def _flush_batch():
|
|
||||||
if not batch:
|
|
||||||
return
|
|
||||||
async with session.begin_nested():
|
|
||||||
for obj in batch:
|
|
||||||
session.add(obj)
|
|
||||||
await session.flush()
|
|
||||||
flushed_ids.extend([r["player_id"] for r in batch])
|
|
||||||
batch.clear()
|
|
||||||
|
|
||||||
for rec in pending:
|
|
||||||
batch.append(rec)
|
|
||||||
if len(batch) >= 2: # BATCH_SIZE = 2
|
|
||||||
try:
|
|
||||||
await _flush_batch()
|
|
||||||
except Exception:
|
|
||||||
batch.clear()
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 最终 flush
|
|
||||||
try:
|
|
||||||
await _flush_batch()
|
|
||||||
except Exception:
|
|
||||||
batch.clear()
|
|
||||||
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 断言:flush 成功的记录是第 1 批(id=0,1)和第 3 批(id=4,5)
|
|
||||||
# 第 2 批(id=2,3)因 IntegrityError 被 savepoint 回滚
|
|
||||||
# 关键:第 1 批和第 3 批的记录必须仍在 flushed_ids 中
|
|
||||||
assert 0 in flushed_ids, "第 1 批记录 0 不应丢失"
|
|
||||||
assert 1 in flushed_ids, "第 1 批记录 1 不应丢失"
|
|
||||||
assert 4 in flushed_ids or 5 in flushed_ids, "第 3 批记录不应丢失"
|
|
||||||
# 第 2 批(flush 失败的)不应在 flushed_ids 中
|
|
||||||
assert 2 not in flushed_ids, "第 2 批应被回滚"
|
|
||||||
assert 3 not in flushed_ids, "第 2 批应被回滚"
|
|
||||||
print("PASS: IntegrityError 只回滚失败批次,其它批次不丢失")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_begin_nested_is_used():
|
|
||||||
"""验证 begin_nested() 被调用(而非全事务 rollback)。"""
|
|
||||||
session = FakeSession()
|
|
||||||
|
|
||||||
batch = [{"player_id": i, "player_name": f"P{i}", "team_id": 1,
|
|
||||||
"fixture_id": 100 + i, "injury_type": "None",
|
|
||||||
"reason": None, "injury_date": None, "return_date": None}
|
|
||||||
for i in range(3)]
|
|
||||||
|
|
||||||
async def _flush_batch():
|
|
||||||
if not batch:
|
|
||||||
return
|
|
||||||
async with session.begin_nested():
|
|
||||||
for obj in batch:
|
|
||||||
session.add(obj)
|
|
||||||
await session.flush()
|
|
||||||
batch.clear()
|
|
||||||
|
|
||||||
try:
|
|
||||||
await _flush_batch()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 验证 begin_nested 被调用(说明使用了 savepoint)
|
|
||||||
assert session.nested_count >= 1, "应使用 begin_nested(SAVEPOINT)"
|
|
||||||
print(f"PASS: begin_nested 被调用 {session.nested_count} 次")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(test_integrity_error_does_not_lose_other_batches())
|
|
||||||
asyncio.run(test_begin_nested_is_used())
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
"""回归测试: 伤停切片区分「本地无数据」与「查询成功但空名单」。
|
|
||||||
|
|
||||||
验证:
|
|
||||||
1. API Key 已配置但 injuries 表无任何记录 → has_data=False
|
|
||||||
2. 有历史伤停记录但当前比赛日无缺阵 → has_data=True
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import date
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.data.injuries import InjuryQueryResult, get_injuries_for_match
|
|
||||||
from src.llm.context_builder import MatchHeader, injuries_slice
|
|
||||||
|
|
||||||
|
|
||||||
def _make_header():
|
|
||||||
return MatchHeader(
|
|
||||||
match_id=999, home_name="A", away_name="B",
|
|
||||||
league_name="X", season=None, match_date="?",
|
|
||||||
match_dt=None, stage=None,
|
|
||||||
home_team_id=1, away_team_id=2, league_id=1,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestNoLocalData:
|
|
||||||
"""区分「本地无数据」与「查询成功但空名单」。"""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_no_local_data_yields_has_data_false(self):
|
|
||||||
"""API Key 已配置但 injuries 表无任何记录 → has_data=False。"""
|
|
||||||
header = _make_header()
|
|
||||||
|
|
||||||
async def mock_query(db, team_id, match_date, as_of=None):
|
|
||||||
return InjuryQueryResult(records=[], query_status="no_local_data")
|
|
||||||
|
|
||||||
with patch("src.data.injuries.get_injuries_for_match", mock_query):
|
|
||||||
result = await injuries_slice(header, before=None)
|
|
||||||
|
|
||||||
assert result.has_data is False, "no_local_data 应 has_data=False"
|
|
||||||
assert "本地尚无伤停数据" in result.text
|
|
||||||
print("PASS: no_local_data → has_data=False")
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_success_empty_yields_has_data_true(self):
|
|
||||||
"""API Key 已配置且查询成功 + 空名单 → has_data=True。"""
|
|
||||||
header = _make_header()
|
|
||||||
|
|
||||||
async def mock_query(db, team_id, match_date, as_of=None):
|
|
||||||
return InjuryQueryResult(records=[], query_status="success")
|
|
||||||
|
|
||||||
with patch("src.data.injuries.get_injuries_for_match", mock_query):
|
|
||||||
result = await injuries_slice(header, before=None)
|
|
||||||
|
|
||||||
assert result.has_data is True, "success + 空名单应 has_data=True"
|
|
||||||
assert "当前无伤停记录" in result.text
|
|
||||||
print("PASS: success + empty → has_data=True")
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_mixed_status_uses_has_data_false(self):
|
|
||||||
"""主队 success + 客队 no_local_data → has_data=False(保守)。"""
|
|
||||||
header = _make_header()
|
|
||||||
|
|
||||||
async def mock_query(db, team_id, match_date, as_of=None):
|
|
||||||
if team_id == 1:
|
|
||||||
return InjuryQueryResult(records=[], query_status="success")
|
|
||||||
return InjuryQueryResult(records=[], query_status="no_local_data")
|
|
||||||
|
|
||||||
with patch("src.data.injuries.get_injuries_for_match", mock_query):
|
|
||||||
result = await injuries_slice(header, before=None)
|
|
||||||
|
|
||||||
# 任一 no_local_data → 保守 has_data=False
|
|
||||||
assert result.has_data is False
|
|
||||||
print("PASS: mixed status保守 has_data=False")
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
"""回归测试: 伤停数据管线 5 项正确性修复。
|
|
||||||
|
|
||||||
Fix 1: IntegrityError 后不整批回滚
|
|
||||||
Fix 2: return_date 正确解析
|
|
||||||
Fix 3: retrieved_at 用 date() 比较避免当天不可见
|
|
||||||
Fix 4: partial unique index 防止 NULL 重复
|
|
||||||
Fix 5: 缓存 TTL 从 7 天改为 6 小时
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.data.injuries import _CACHE_TTL_HOURS, fetch_injuries
|
|
||||||
|
|
||||||
|
|
||||||
class TestCacheTTL:
|
|
||||||
"""Fix 5: 缓存 TTL 应为 6 小时。"""
|
|
||||||
|
|
||||||
def test_cache_ttl_is_6_hours(self):
|
|
||||||
assert _CACHE_TTL_HOURS == 6, f"缓存 TTL 应为 6 小时,实际 {_CACHE_TTL_HOURS}"
|
|
||||||
|
|
||||||
def test_cache_expiry_logic(self):
|
|
||||||
"""验证缓存过期逻辑:超过 TTL 返回 None(触发重新采集)。"""
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
|
||||||
cache_file = Path(tmpdir) / "test_cache.json"
|
|
||||||
cache_file.write_text("[]")
|
|
||||||
|
|
||||||
# 模拟 7 小时前写入
|
|
||||||
old_time = time.time() - 7 * 3600
|
|
||||||
import os
|
|
||||||
os.utime(cache_file, (old_time, old_time))
|
|
||||||
|
|
||||||
age_hours = (time.time() - cache_file.stat().st_mtime) / 3600
|
|
||||||
assert age_hours > _CACHE_TTL_HOURS, "7 小时前的缓存应已过期"
|
|
||||||
|
|
||||||
def test_cache_hit_within_ttl(self):
|
|
||||||
"""验证 TTL 内缓存命中。"""
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
|
||||||
cache_file = Path(tmpdir) / "test_cache.json"
|
|
||||||
cache_file.write_text("[]")
|
|
||||||
|
|
||||||
# 1 小时前写入
|
|
||||||
old_time = time.time() - 3600
|
|
||||||
import os
|
|
||||||
os.utime(cache_file, (old_time, old_time))
|
|
||||||
|
|
||||||
age_hours = (time.time() - cache_file.stat().st_mtime) / 3600
|
|
||||||
assert age_hours < _CACHE_TTL_HOURS, "1 小时前的缓存应在 TTL 内"
|
|
||||||
|
|
||||||
|
|
||||||
class TestReturnDateParsing:
|
|
||||||
"""Fix 2: return_date 应从 API 响应正确解析并写入。"""
|
|
||||||
|
|
||||||
def test_parse_return_date_iso(self):
|
|
||||||
"""ISO 格式 return_date 应正确解析为 date 对象。"""
|
|
||||||
from datetime import datetime, date
|
|
||||||
raw = "2026-02-15"
|
|
||||||
dt = datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
|
|
||||||
assert dt.date() == date(2026, 2, 15)
|
|
||||||
|
|
||||||
def test_parse_return_date_with_time(self):
|
|
||||||
"""带时间的 return_date 应截取日期部分。"""
|
|
||||||
from datetime import datetime, date
|
|
||||||
raw = "2026-03-01T00:00:00Z"
|
|
||||||
dt = datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
|
|
||||||
assert dt.date() == date(2026, 3, 1)
|
|
||||||
|
|
||||||
def test_parse_return_date_none(self):
|
|
||||||
"""None 或空值应返回 None。"""
|
|
||||||
return_date_raw = None
|
|
||||||
return_date = None
|
|
||||||
if return_date_raw:
|
|
||||||
return_date = "should not reach"
|
|
||||||
assert return_date is None
|
|
||||||
|
|
||||||
def test_parse_return_date_invalid(self):
|
|
||||||
"""无效日期应返回 None 而非抛异常。"""
|
|
||||||
from datetime import datetime
|
|
||||||
raw = "invalid-date"
|
|
||||||
return_date = None
|
|
||||||
try:
|
|
||||||
dt = datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
|
|
||||||
return_date = dt.date()
|
|
||||||
except (ValueError, AttributeError):
|
|
||||||
pass
|
|
||||||
assert return_date is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestQueryDateComparison:
|
|
||||||
"""Fix 3: retrieved_at 比较应使用 date() 避免时区截断。"""
|
|
||||||
|
|
||||||
def test_date_comparison_handles_same_day(self):
|
|
||||||
"""核心 bug: 当天白天采到的数据应对当晚比赛可见。
|
|
||||||
|
|
||||||
retrieved_at = 2026-01-15 14:00:00+00 (timestamptz)
|
|
||||||
as_of = 2026-01-15 (date)
|
|
||||||
|
|
||||||
错误的比较: retrieved_at <= as_of
|
|
||||||
→ PostgreSQL 将 as_of 视为 2026-01-15 00:00:00+00
|
|
||||||
→ 14:00 <= 00:00 → False → 数据不可见!
|
|
||||||
|
|
||||||
正确的比较: date(retrieved_at) <= as_of
|
|
||||||
→ 2026-01-15 <= 2026-01-15 → True → 数据可见
|
|
||||||
"""
|
|
||||||
from datetime import datetime, date, timezone
|
|
||||||
|
|
||||||
retrieved_at = datetime(2026, 1, 15, 14, 0, tzinfo=timezone.utc)
|
|
||||||
as_of_date = date(2026, 1, 15)
|
|
||||||
|
|
||||||
# 错误的比较方式(原 bug)
|
|
||||||
# PostgreSQL 会将 date 转为 timestamptz at midnight
|
|
||||||
as_of_as_datetime = datetime(2026, 1, 15, 0, 0, tzinfo=timezone.utc)
|
|
||||||
wrong_result = retrieved_at <= as_of_as_datetime # False
|
|
||||||
|
|
||||||
# 正确的比较方式(修复后)
|
|
||||||
correct_result = retrieved_at.date() <= as_of_date # True
|
|
||||||
|
|
||||||
assert wrong_result is False, "原 bug 演示: 白天数据对当晚比赛不可见"
|
|
||||||
assert correct_result is True, "修复后: 白天数据对当晚比赛可见"
|
|
||||||
|
|
||||||
|
|
||||||
class TestPartialUniqueIndex:
|
|
||||||
"""Fix 4: partial unique index 防止 NULL 重复。"""
|
|
||||||
|
|
||||||
def test_orm_declares_partial_index(self):
|
|
||||||
"""ORM 模型应声明 partial unique index。"""
|
|
||||||
from sqlalchemy import and_
|
|
||||||
from src.db.models import Injury
|
|
||||||
|
|
||||||
# 验证 __table_args__ 包含 partial index
|
|
||||||
found_partial = False
|
|
||||||
for arg in Injury.__table_args__:
|
|
||||||
if hasattr(arg, "name") and arg.name == "ix_injuries_player_fixture":
|
|
||||||
# 验证是 unique 且有 postgresql_where
|
|
||||||
assert arg.unique is True, "应为唯一索引"
|
|
||||||
# postgresql_where 应排除 NULL
|
|
||||||
found_partial = True
|
|
||||||
|
|
||||||
assert found_partial, "Injury 模型应声明 ix_injuries_player_fixture 索引"
|
|
||||||
|
|
||||||
def test_migration_creates_partial_index(self):
|
|
||||||
"""迁移文件应包含 partial index 创建逻辑。"""
|
|
||||||
import os
|
|
||||||
migration_path = "/.octop/workspaces/CA7PFH/Profeto/alembic/versions/0012_injuries_partial_unique_and_return_date.py"
|
|
||||||
assert os.path.exists(migration_path), "迁移文件 0012 应存在"
|
|
||||||
|
|
||||||
with open(migration_path) as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
assert "CREATE UNIQUE INDEX ix_injuries_player_fixture" in content
|
|
||||||
assert "WHERE player_id IS NOT NULL" in content
|
|
||||||
assert "fixture_id IS NOT NULL" in content
|
|
||||||
|
|
||||||
|
|
||||||
class TestInjuriesSliceIntegration:
|
|
||||||
"""验证 injuries_slice 仍正常工作(未被破坏)。"""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_injuries_slice_with_cutoff(self):
|
|
||||||
"""injuries_slice 应正确传递 before=cutoff 到 get_injuries_for_match。"""
|
|
||||||
from datetime import datetime, timezone, timedelta
|
|
||||||
from src.llm.context_builder import injuries_slice, MatchHeader
|
|
||||||
|
|
||||||
header = MatchHeader(
|
|
||||||
match_id=999, home_name="A", away_name="B",
|
|
||||||
league_name="X", season=None, match_date="?",
|
|
||||||
match_dt=datetime(2026, 1, 15, 20, 0, tzinfo=timezone.utc),
|
|
||||||
stage=None, home_team_id=1, away_team_id=2, league_id=1,
|
|
||||||
)
|
|
||||||
|
|
||||||
cutoff = datetime(2026, 1, 14, 20, 0, tzinfo=timezone.utc)
|
|
||||||
|
|
||||||
import src.llm.context_builder as cb
|
|
||||||
orig = cb.get_injuries_for_match
|
|
||||||
|
|
||||||
captured_before = []
|
|
||||||
|
|
||||||
async def mock_get_injuries(db, team_id, match_date, as_of=None):
|
|
||||||
captured_before.append((team_id, match_date, as_of))
|
|
||||||
return []
|
|
||||||
|
|
||||||
cb.get_injuries_for_match = mock_get_injuries
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = await injuries_slice(header, before=cutoff)
|
|
||||||
assert str(result) is not None
|
|
||||||
# 验证 before 参数被传递到 get_injuries_for_match
|
|
||||||
assert len(captured_before) == 2 # home + away
|
|
||||||
for team_id, match_date, as_of in captured_before:
|
|
||||||
# as_of 应等于 before (cutoff)
|
|
||||||
assert as_of == cutoff or (hasattr(as_of, 'date') and as_of.date() == cutoff.date()), \
|
|
||||||
f"as_of 应为 cutoff,实际 {as_of}"
|
|
||||||
finally:
|
|
||||||
cb.get_injuries_for_match = orig
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
"""回归测试: 伤停切片区分「查询成功但无人伤停」与「无数据/未接入」。
|
|
||||||
|
|
||||||
验证:
|
|
||||||
1. 查询成功 + 空结果 → has_data=True
|
|
||||||
2. 源未配置 → has_data=False
|
|
||||||
3. 查询异常 → has_data=False
|
|
||||||
4. 查询成功 + 有数据 → has_data=True
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from unittest.mock import MagicMock, AsyncMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.data.injuries import InjuryQueryResult, get_injuries_for_match
|
|
||||||
from src.llm.context_builder import MatchHeader, injuries_slice
|
|
||||||
|
|
||||||
|
|
||||||
def _make_header():
|
|
||||||
return MatchHeader(
|
|
||||||
match_id=999, home_name="A", away_name="B",
|
|
||||||
league_name="X", season=None, match_date="?",
|
|
||||||
match_date=None, stage=None,
|
|
||||||
home_team_id=1, away_team_id=2, league_id=1,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestInjuryQueryResult:
|
|
||||||
"""InjuryQueryResult 基础属性。"""
|
|
||||||
|
|
||||||
def test_has_data_success(self):
|
|
||||||
result = InjuryQueryResult(records=[], query_status="success")
|
|
||||||
assert result.has_data is True
|
|
||||||
|
|
||||||
def test_has_data_source_not_configured(self):
|
|
||||||
result = InjuryQueryResult(records=[], query_status="source_not_configured")
|
|
||||||
assert result.has_data is False
|
|
||||||
|
|
||||||
def test_has_data_query_error(self):
|
|
||||||
result = InjuryQueryResult(records=[], query_status="query_error")
|
|
||||||
assert result.has_data is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestInjuriesSliceEmptyVsNotConfigured:
|
|
||||||
"""injuries_slice 应区分「查询成功但为空」与「无数据/未接入」。"""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_empty_result_has_data_true(self):
|
|
||||||
"""查询成功 + 空结果 → has_data=True,文案显示「当前无伤停记录」。"""
|
|
||||||
header = _make_header()
|
|
||||||
|
|
||||||
# Mock get_injuries_for_match 返回成功但空的结果
|
|
||||||
async def mock_query(db, team_id, match_date, as_of=None):
|
|
||||||
return InjuryQueryResult(records=[], query_status="success")
|
|
||||||
|
|
||||||
with patch("src.llm.context_builder.get_injuries_for_match", mock_query):
|
|
||||||
result = await injuries_slice(header, before=None)
|
|
||||||
|
|
||||||
assert result.has_data is True, "查询成功+空结果应 has_data=True"
|
|
||||||
assert "当前无伤停记录" in result.text, "文案应表明无伤停"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_source_not_configured_has_data_false(self):
|
|
||||||
"""源未配置 → has_data=False。"""
|
|
||||||
header = _make_header()
|
|
||||||
|
|
||||||
async def mock_query(db, team_id, match_date, as_of=None):
|
|
||||||
return InjuryQueryResult(records=[], query_status="source_not_configured")
|
|
||||||
|
|
||||||
with patch("src.llm.context_builder.get_injuries_for_match", mock_query):
|
|
||||||
result = await injuries_slice(header, before=None)
|
|
||||||
|
|
||||||
assert result.has_data is False, "源未配置应 has_data=False"
|
|
||||||
assert "伤停源未配置" in result.text
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_query_error_has_data_false(self):
|
|
||||||
"""查询异常 → has_data=False。"""
|
|
||||||
header = _make_header()
|
|
||||||
|
|
||||||
async def mock_query(db, team_id, match_date, as_of=None):
|
|
||||||
return InjuryQueryResult(records=[], query_status="query_error")
|
|
||||||
|
|
||||||
with patch("src.llm.context_builder.get_injuries_for_match", mock_query):
|
|
||||||
result = await injuries_slice(header, before=None)
|
|
||||||
|
|
||||||
assert result.has_data is False, "查询异常应 has_data=False"
|
|
||||||
assert "查询异常" in result.text
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_with_records_has_data_true(self):
|
|
||||||
"""查询成功 + 有数据 → has_data=True。"""
|
|
||||||
header = _make_header()
|
|
||||||
|
|
||||||
mock_inj = MagicMock()
|
|
||||||
mock_inj.reason = "Hamstring"
|
|
||||||
mock_inj.injury_type = None
|
|
||||||
mock_inj.player_name = "Player A"
|
|
||||||
|
|
||||||
async def mock_query(db, team_id, match_date, as_of=None):
|
|
||||||
if team_id == 1:
|
|
||||||
return InjuryQueryResult(records=[mock_inj], query_status="success")
|
|
||||||
return InjuryQueryResult(records=[], query_status="success")
|
|
||||||
|
|
||||||
with patch("src.llm.context_builder.get_injuries_for_match", mock_query):
|
|
||||||
result = await injuries_slice(header, before=None)
|
|
||||||
|
|
||||||
assert result.has_data is True
|
|
||||||
assert "Player A" in result.text
|
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
"""KeyRing 多 key 轮换单元测试。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.data.key_ring import KeyRing, parse_keys, get_key_ring
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseKeys:
|
||||||
|
def test_single_key(self):
|
||||||
|
assert parse_keys("abc123") == ["abc123"]
|
||||||
|
|
||||||
|
def test_comma_separated(self):
|
||||||
|
assert parse_keys("k1, k2,k3") == ["k1", "k2", "k3"]
|
||||||
|
|
||||||
|
def test_semicolon_separated(self):
|
||||||
|
assert parse_keys("k1;k2;k3") == ["k1", "k2", "k3"]
|
||||||
|
|
||||||
|
def test_newline_separated(self):
|
||||||
|
assert parse_keys("k1\nk2\nk3") == ["k1", "k2", "k3"]
|
||||||
|
|
||||||
|
def test_mixed_separators(self):
|
||||||
|
assert parse_keys("k1, k2; k3\nk4") == ["k1", "k2", "k3", "k4"]
|
||||||
|
|
||||||
|
def test_empty(self):
|
||||||
|
assert parse_keys("") == []
|
||||||
|
assert parse_keys(None) == []
|
||||||
|
assert parse_keys(" , ; ") == []
|
||||||
|
|
||||||
|
def test_strips_whitespace(self):
|
||||||
|
assert parse_keys(" a , b ") == ["a", "b"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestKeyRingSingleKey:
|
||||||
|
"""单 key 场景:行为与之前一致。"""
|
||||||
|
|
||||||
|
def test_get_returns_key(self):
|
||||||
|
ring = KeyRing(["only-key"])
|
||||||
|
assert ring.get() == "only-key"
|
||||||
|
assert ring.active_key == "only-key"
|
||||||
|
|
||||||
|
def test_no_rotation(self):
|
||||||
|
ring = KeyRing(["key"])
|
||||||
|
ring.report_rate_limited()
|
||||||
|
# 单 key 切换后仍是自己
|
||||||
|
assert ring.get() == "key"
|
||||||
|
|
||||||
|
def test_empty_keys(self):
|
||||||
|
ring = KeyRing([])
|
||||||
|
assert ring.get() is None
|
||||||
|
assert ring.active_key is None
|
||||||
|
|
||||||
|
def test_has_multiple_false(self):
|
||||||
|
ring = KeyRing(["key"])
|
||||||
|
assert ring.has_multiple is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestKeyRingMultiKey:
|
||||||
|
"""多 key 场景:429 自动轮换。"""
|
||||||
|
|
||||||
|
def test_get_rounds_robin(self):
|
||||||
|
ring = KeyRing(["a", "b", "c"])
|
||||||
|
# 前三次 get 依次返回 a, b, c
|
||||||
|
assert ring.get() == "a"
|
||||||
|
assert ring.get() == "a" # 不报告限流时保持当前 key
|
||||||
|
# 手动推进:通过 report 后 get
|
||||||
|
ring.report_rate_limited("a")
|
||||||
|
# a 被冷却,下一个可用的是 b
|
||||||
|
assert ring.get() == "b"
|
||||||
|
|
||||||
|
def test_rate_limit_skips_key(self):
|
||||||
|
ring = KeyRing(["a", "b", "c"], cooldown_seconds=60.0)
|
||||||
|
key = ring.get()
|
||||||
|
assert key == "a"
|
||||||
|
new_key = ring.report_rate_limited("a")
|
||||||
|
assert new_key == "b"
|
||||||
|
# 再次 get 应继续是 b(可用)
|
||||||
|
assert ring.get() == "b"
|
||||||
|
|
||||||
|
def test_cycle_back_to_first(self):
|
||||||
|
ring = KeyRing(["a", "b"], cooldown_seconds=0.1)
|
||||||
|
ring.report_rate_limited("a")
|
||||||
|
# b 可用
|
||||||
|
assert ring.get() == "b"
|
||||||
|
ring.report_rate_limited("b")
|
||||||
|
# a 仍在冷却,b 也在冷却 → 选最早过期的(可能是 a)
|
||||||
|
key = ring.get()
|
||||||
|
assert key in ("a", "b")
|
||||||
|
|
||||||
|
def test_cooldown_expires(self):
|
||||||
|
ring = KeyRing(["a", "b"], cooldown_seconds=0.05)
|
||||||
|
ring.report_rate_limited("a")
|
||||||
|
assert ring.get() == "b"
|
||||||
|
# 等 a 的冷却过期
|
||||||
|
time.sleep(0.08)
|
||||||
|
# 现在 get 应该能找到可用的 key(b 或 a 都行,取决于指针)
|
||||||
|
key = ring.get()
|
||||||
|
assert key in ("a", "b")
|
||||||
|
|
||||||
|
def test_wait_if_all_blocked(self):
|
||||||
|
ring = KeyRing(["a", "b"], cooldown_seconds=1.0)
|
||||||
|
ring.report_rate_limited("a")
|
||||||
|
ring.report_rate_limited("b")
|
||||||
|
wait = ring.wait_if_all_blocked()
|
||||||
|
assert wait > 0 # 应返回正数等待时间
|
||||||
|
|
||||||
|
def test_wait_if_not_all_blocked(self):
|
||||||
|
ring = KeyRing(["a", "b"], cooldown_seconds=1.0)
|
||||||
|
ring.report_rate_limited("a")
|
||||||
|
# b 仍可用
|
||||||
|
assert ring.wait_if_all_blocked() == 0.0
|
||||||
|
|
||||||
|
def test_stats(self):
|
||||||
|
ring = KeyRing(["a" * 12, "b" * 12], cooldown_seconds=1.0)
|
||||||
|
ring.report_rate_limited("a" * 12)
|
||||||
|
st = ring.stats()
|
||||||
|
assert st["total"] == 2
|
||||||
|
assert st["keys"][0]["blocked_remaining"] > 0
|
||||||
|
assert st["keys"][1]["blocked_remaining"] == 0
|
||||||
|
# 脱敏
|
||||||
|
assert "***" in st["keys"][0]["masked"] or "..." in st["keys"][0]["masked"]
|
||||||
|
|
||||||
|
def test_all_keys_property(self):
|
||||||
|
ring = KeyRing(["x", "y"])
|
||||||
|
assert ring.all_keys == ["x", "y"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestKeyRingHotUpdate:
|
||||||
|
"""热更新 key 列表。"""
|
||||||
|
|
||||||
|
def test_setter_clears_state(self):
|
||||||
|
ring = KeyRing(["a", "b"])
|
||||||
|
ring.report_rate_limited("a")
|
||||||
|
assert ring.get() == "b"
|
||||||
|
# 更新 key 列表
|
||||||
|
ring._keys = ["c", "d"]
|
||||||
|
ring._blocked_until.clear()
|
||||||
|
ring._index = 0
|
||||||
|
assert ring.get() == "c"
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetKeyRing:
|
||||||
|
def test_singleton_per_base(self):
|
||||||
|
r1 = get_key_ring("https://api.test.com", "k1, k2")
|
||||||
|
r2 = get_key_ring("https://api.test.com", "k1, k2")
|
||||||
|
assert r1 is r2
|
||||||
|
|
||||||
|
def test_different_base_isolated(self):
|
||||||
|
r1 = get_key_ring("https://a.com", "k1")
|
||||||
|
r2 = get_key_ring("https://b.com", "k2")
|
||||||
|
assert r1 is not r2
|
||||||
|
assert r1.get() == "k1"
|
||||||
|
assert r2.get() == "k2"
|
||||||
|
|
||||||
|
def test_hot_update_keys(self):
|
||||||
|
ring = get_key_ring("https://hot.com", "k1, k2")
|
||||||
|
assert set(ring.all_keys) == {"k1", "k2"}
|
||||||
|
# 更新(同 base 会命中缓存,触发热更新)
|
||||||
|
ring2 = get_key_ring("https://hot.com", "k3, k4")
|
||||||
|
assert ring2 is ring
|
||||||
|
assert set(ring.all_keys) == {"k3", "k4"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestKeyRingAsyncSafety:
|
||||||
|
"""async 并发场景下单 event loop 不需要锁,但验证交替 429 不会死锁。"""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_concurrent_get(self):
|
||||||
|
ring = KeyRing(["a", "b", "c"])
|
||||||
|
|
||||||
|
async def worker():
|
||||||
|
for _ in range(20):
|
||||||
|
key = ring.get()
|
||||||
|
assert key in ("a", "b", "c")
|
||||||
|
# 模拟偶发 429
|
||||||
|
if hash(key) % 3 == 0:
|
||||||
|
ring.report_rate_limited(key)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
await asyncio.gather(*(worker() for _ in range(5)))
|
||||||
@@ -35,40 +35,49 @@ class TestMultiAgentCutoffPropagation:
|
|||||||
async def test_backtest_computes_cutoff_from_match_dt_minus_1_day(self):
|
async def test_backtest_computes_cutoff_from_match_dt_minus_1_day(self):
|
||||||
"""backtest=True → cutoff = match_dt - 1 天,传给所有切片。"""
|
"""backtest=True → cutoff = match_dt - 1 天,传给所有切片。"""
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from unittest.mock import patch
|
||||||
import src.llm.agents.orchestrator as orch
|
import src.llm.agents.orchestrator as orch
|
||||||
|
|
||||||
match_dt = datetime(2026, 1, 15, 20, 0, tzinfo=timezone.utc)
|
match_dt = datetime(2026, 1, 15, 20, 0, tzinfo=timezone.utc)
|
||||||
header = _make_header(match_dt)
|
header = _make_header(match_dt)
|
||||||
|
|
||||||
captured_before = []
|
captured_before = []
|
||||||
orig_run_specialists = orch.run_specialists
|
|
||||||
|
|
||||||
async def mock_run_specialists(header, *, version, before=None):
|
async def mock_run_specialists(header, *, version, before=None, model_override=None):
|
||||||
captured_before.append(before)
|
captured_before.append(before)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
orch.run_specialists = mock_run_specialists
|
async def mock_header(mid, db=None):
|
||||||
orch.load_match_header = lambda mid, db=None: header
|
# load_match_header 是 async,必须是 async 函数;
|
||||||
orch._agent_provider = lambda agent_id, **kw: MagicMock(model="test")
|
# 且必须走 patch.object(orch 模块属性),因为 predict_match_multi
|
||||||
|
# 通过模块命名空间解析该名字。裸赋值 orch.load_match_header 同样有效,
|
||||||
|
# 但用 patch 可保证退出时精确还原,不向后续测试泄漏。
|
||||||
|
return header
|
||||||
|
|
||||||
try:
|
async def mock_provider(agent_id, *, tier, model_override=None):
|
||||||
|
# 真实契约是 async(见 orchestrator._agent_provider),同步 lambda
|
||||||
|
# 会让 `await _agent_provider(...)` 抛 TypeError 并被吞掉。
|
||||||
|
return MagicMock(model="test")
|
||||||
|
|
||||||
|
with patch.object(orch, "run_specialists", mock_run_specialists), \
|
||||||
|
patch.object(orch, "load_match_header", mock_header), \
|
||||||
|
patch.object(orch, "_agent_provider", mock_provider):
|
||||||
try:
|
try:
|
||||||
await orch.predict_match_multi(999, backtest=True)
|
await orch.predict_match_multi(999, backtest=True)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # 后续 aggregator 调用会因 mock 不全而失败,不影响 cutoff 测试
|
pass # 后续 aggregator 调用会因 mock 不全而失败,不影响 cutoff 测试
|
||||||
|
|
||||||
assert len(captured_before) == 1
|
assert len(captured_before) == 1
|
||||||
expected_cutoff = match_dt - timedelta(days=1)
|
expected_cutoff = match_dt - timedelta(days=1)
|
||||||
assert captured_before[0] == expected_cutoff, (
|
assert captured_before[0] == expected_cutoff, (
|
||||||
f"backtest cutoff 应为 {expected_cutoff},实际 {captured_before[0]}"
|
f"backtest cutoff 应为 {expected_cutoff},实际 {captured_before[0]}"
|
||||||
)
|
)
|
||||||
finally:
|
|
||||||
orch.run_specialists = orig_run_specialists
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_explicit_cutoff_at_overrides_backtest(self):
|
async def test_explicit_cutoff_at_overrides_backtest(self):
|
||||||
"""显式 cutoff_at 优先于 backtest 自动计算。"""
|
"""显式 cutoff_at 优先于 backtest 自动计算。"""
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from unittest.mock import patch
|
||||||
import src.llm.agents.orchestrator as orch
|
import src.llm.agents.orchestrator as orch
|
||||||
|
|
||||||
match_dt = datetime(2026, 1, 15, 20, 0, tzinfo=timezone.utc)
|
match_dt = datetime(2026, 1, 15, 20, 0, tzinfo=timezone.utc)
|
||||||
@@ -76,97 +85,89 @@ class TestMultiAgentCutoffPropagation:
|
|||||||
header = _make_header(match_dt)
|
header = _make_header(match_dt)
|
||||||
|
|
||||||
captured_before = []
|
captured_before = []
|
||||||
orig_run_specialists = orch.run_specialists
|
|
||||||
|
|
||||||
async def mock_run_specialists(header, *, version, before=None):
|
async def mock_run_specialists(header, *, version, before=None, model_override=None):
|
||||||
captured_before.append(before)
|
captured_before.append(before)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
orch.run_specialists = mock_run_specialists
|
async def mock_header(mid, db=None):
|
||||||
orch.load_match_header = lambda mid, db=None: header
|
return header
|
||||||
orch._agent_provider = lambda agent_id, **kw: MagicMock(model="test")
|
|
||||||
|
|
||||||
try:
|
async def mock_provider(agent_id, *, tier, model_override=None):
|
||||||
|
return MagicMock(model="test")
|
||||||
|
|
||||||
|
with patch.object(orch, "run_specialists", mock_run_specialists), \
|
||||||
|
patch.object(orch, "load_match_header", mock_header), \
|
||||||
|
patch.object(orch, "_agent_provider", mock_provider):
|
||||||
try:
|
try:
|
||||||
await orch.predict_match_multi(999, backtest=True, cutoff_at=explicit_cutoff)
|
await orch.predict_match_multi(999, backtest=True, cutoff_at=explicit_cutoff)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
assert captured_before[0] == explicit_cutoff
|
assert captured_before[0] == explicit_cutoff
|
||||||
finally:
|
|
||||||
orch.run_specialists = orig_run_specialists
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_normal_mode_cutoff_is_match_dt(self):
|
async def test_normal_mode_cutoff_is_match_dt(self):
|
||||||
"""非回测模式,无显式 cutoff → cutoff = match_dt。"""
|
"""非回测模式,无显式 cutoff → cutoff = match_dt。"""
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from unittest.mock import patch
|
||||||
import src.llm.agents.orchestrator as orch
|
import src.llm.agents.orchestrator as orch
|
||||||
|
|
||||||
match_dt = datetime(2026, 1, 15, 20, 0, tzinfo=timezone.utc)
|
match_dt = datetime(2026, 1, 15, 20, 0, tzinfo=timezone.utc)
|
||||||
header = _make_header(match_dt)
|
header = _make_header(match_dt)
|
||||||
|
|
||||||
captured_before = []
|
captured_before = []
|
||||||
orig_run_specialists = orch.run_specialists
|
|
||||||
|
|
||||||
async def mock_run_specialists(header, *, version, before=None):
|
async def mock_run_specialists(header, *, version, before=None, model_override=None):
|
||||||
captured_before.append(before)
|
captured_before.append(before)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
orch.run_specialists = mock_run_specialists
|
async def mock_header(mid, db=None):
|
||||||
orch.load_match_header = lambda mid, db=None: header
|
return header
|
||||||
orch._agent_provider = lambda agent_id, **kw: MagicMock(model="test")
|
|
||||||
|
|
||||||
try:
|
async def mock_provider(agent_id, *, tier, model_override=None):
|
||||||
|
return MagicMock(model="test")
|
||||||
|
|
||||||
|
with patch.object(orch, "run_specialists", mock_run_specialists), \
|
||||||
|
patch.object(orch, "load_match_header", mock_header), \
|
||||||
|
patch.object(orch, "_agent_provider", mock_provider):
|
||||||
try:
|
try:
|
||||||
await orch.predict_match_multi(999, backtest=False)
|
await orch.predict_match_multi(999, backtest=False)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
assert captured_before[0] == match_dt
|
assert captured_before[0] == match_dt
|
||||||
finally:
|
|
||||||
orch.run_specialists = orig_run_specialists
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_prediction_cutoff_at_stored_not_match_dt(self):
|
async def test_prediction_cutoff_at_stored_not_match_dt(self):
|
||||||
"""Prediction 写入时 prediction_cutoff_at = 真正 cutoff,非 match_dt。"""
|
"""Prediction 写入时 prediction_cutoff_at = 真正 cutoff,非 match_dt。"""
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from src.llm.predict import _predict_single, PredictResult
|
from unittest.mock import patch
|
||||||
|
import src.llm.predict as pred
|
||||||
|
|
||||||
match_dt = datetime(2026, 1, 15, 20, 0, tzinfo=timezone.utc)
|
match_dt = datetime(2026, 1, 15, 20, 0, tzinfo=timezone.utc)
|
||||||
expected_cutoff = match_dt - timedelta(days=1)
|
expected_cutoff = match_dt - timedelta(days=1)
|
||||||
|
|
||||||
# Mock build_context to return a context with cutoff
|
|
||||||
import src.llm.predict as pred
|
|
||||||
orig_build = pred.build_context
|
|
||||||
|
|
||||||
class FakeContext:
|
class FakeContext:
|
||||||
text = "fake"
|
text = "fake"
|
||||||
match_dt = match_dt
|
|
||||||
cutoff = expected_cutoff
|
cutoff = expected_cutoff
|
||||||
|
|
||||||
async def fake_build(match_id, **kw):
|
FakeContext.match_dt = match_dt
|
||||||
|
|
||||||
|
call_args = {}
|
||||||
|
|
||||||
|
async def tracking_build(match_id, **kw):
|
||||||
|
call_args.update(kw)
|
||||||
return FakeContext()
|
return FakeContext()
|
||||||
|
|
||||||
pred.build_context = fake_build
|
with patch.object(pred, "build_context", tracking_build):
|
||||||
pred._upsert_prediction = lambda session, **kw: MagicMock(id=1, **kw.get("values", {}))
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 此处只验证 cutoff 参数传递,实际 LLM 调用会被 mock 阻断
|
|
||||||
# 重点: build_context 被调用时传入 backtest=True 和正确的 cutoff
|
|
||||||
call_args = {}
|
|
||||||
async def tracking_build(match_id, **kw):
|
|
||||||
call_args.update(kw)
|
|
||||||
return FakeContext()
|
|
||||||
pred.build_context = tracking_build
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await _predict_single(999, backtest=True)
|
await pred._predict_single(999, backtest=True)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
assert call_args.get("backtest") is True, "backtest=True 应传递给 build_context"
|
# 重点: build_context 被调用时传入 backtest=True 和正确的 cutoff
|
||||||
finally:
|
assert call_args.get("backtest") is True, "backtest=True 应传递给 build_context"
|
||||||
pred.build_context = orig_build
|
|
||||||
|
|
||||||
|
|
||||||
class TestBacktestXgNotVisible:
|
class TestBacktestXgNotVisible:
|
||||||
@@ -175,8 +176,8 @@ class TestBacktestXgNotVisible:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_stats_slice_respects_cutoff_for_xg_availability(self):
|
async def test_stats_slice_respects_cutoff_for_xg_availability(self):
|
||||||
"""available_at > cutoff 的 xG 数据不应被切片使用。"""
|
"""available_at > cutoff 的 xG 数据不应被切片使用。"""
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timezone
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
cutoff = datetime(2026, 1, 13, 20, 0, tzinfo=timezone.utc) # match_date - 2天
|
cutoff = datetime(2026, 1, 13, 20, 0, tzinfo=timezone.utc) # match_date - 2天
|
||||||
match_dt = datetime(2026, 1, 15, 20, 0, tzinfo=timezone.utc)
|
match_dt = datetime(2026, 1, 15, 20, 0, tzinfo=timezone.utc)
|
||||||
@@ -206,7 +207,6 @@ class TestBacktestXgNotVisible:
|
|||||||
header = _make_header(match_dt)
|
header = _make_header(match_dt)
|
||||||
|
|
||||||
import src.llm.context_builder as cb
|
import src.llm.context_builder as cb
|
||||||
orig_get_form = cb._get_form
|
|
||||||
|
|
||||||
async def mock_get_form(db, team_id, before, *, limit=10):
|
async def mock_get_form(db, team_id, before, *, limit=10):
|
||||||
# before=cutoff(1月13日),比赛在1月15日,满足 before 条件
|
# before=cutoff(1月13日),比赛在1月15日,满足 before 条件
|
||||||
@@ -214,14 +214,10 @@ class TestBacktestXgNotVisible:
|
|||||||
return [hist_match]
|
return [hist_match]
|
||||||
return []
|
return []
|
||||||
|
|
||||||
cb._get_form = mock_get_form
|
with patch.object(cb, "_get_form", mock_get_form):
|
||||||
|
|
||||||
try:
|
|
||||||
result = await cb.stats_slice(header, limit=10, before=cutoff)
|
result = await cb.stats_slice(header, limit=10, before=cutoff)
|
||||||
text = str(result)
|
text = str(result)
|
||||||
# xG 在 cutoff 之后才 available,不应出现在切片
|
# xG 在 cutoff 之后才 available,不应出现在切片
|
||||||
assert "2.50" not in text, f"xG 2.50 不应在切片中(available_at > cutoff):\n{text}"
|
assert "2.50" not in text, f"xG 2.50 不应在切片中(available_at > cutoff):\n{text}"
|
||||||
# 但无比分时仍应显示进球数据
|
# 但无比分时仍应显示进球数据
|
||||||
assert "无比分数据" in text or "场均进球" in text, f"无比分时仍应显示基本数据:\n{text}"
|
assert "无比分数据" in text or "场均进球" in text, f"无比分时仍应显示基本数据:\n{text}"
|
||||||
finally:
|
|
||||||
cb._get_form = orig_get_form
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ def _all_error_reports():
|
|||||||
AgentReport(agent="form", status="error", analysis="slice failed"),
|
AgentReport(agent="form", status="error", analysis="slice failed"),
|
||||||
AgentReport(agent="stats", status="error", analysis="slice failed"),
|
AgentReport(agent="stats", status="error", analysis="slice failed"),
|
||||||
AgentReport(agent="home_away", status="error", analysis="slice failed"),
|
AgentReport(agent="home_away", status="error", analysis="slice failed"),
|
||||||
AgentReport(agent="injuries", status="error", analysis="slice failed"),
|
AgentReport(agent="standings", status="error", analysis="slice failed"),
|
||||||
AgentReport(agent="h2h", status="error", analysis="slice failed"),
|
AgentReport(agent="h2h", status="error", analysis="slice failed"),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ def _all_no_data_reports():
|
|||||||
AgentReport(agent="form", status="no_data", analysis="无数据"),
|
AgentReport(agent="form", status="no_data", analysis="无数据"),
|
||||||
AgentReport(agent="stats", status="no_data", analysis="无数据"),
|
AgentReport(agent="stats", status="no_data", analysis="无数据"),
|
||||||
AgentReport(agent="home_away", status="no_data", analysis="无数据"),
|
AgentReport(agent="home_away", status="no_data", analysis="无数据"),
|
||||||
AgentReport(agent="injuries", status="no_data", analysis="无数据"),
|
AgentReport(agent="standings", status="no_data", analysis="无数据"),
|
||||||
AgentReport(agent="h2h", status="no_data", analysis="无数据"),
|
AgentReport(agent="h2h", status="no_data", analysis="无数据"),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ def _mixed_reports():
|
|||||||
AgentReport(agent="form", status="ok", analysis="good"),
|
AgentReport(agent="form", status="ok", analysis="good"),
|
||||||
AgentReport(agent="stats", status="error", analysis="failed"),
|
AgentReport(agent="stats", status="error", analysis="failed"),
|
||||||
AgentReport(agent="home_away", status="no_data", analysis="无数据"),
|
AgentReport(agent="home_away", status="no_data", analysis="无数据"),
|
||||||
AgentReport(agent="injuries", status="error", analysis="failed"),
|
AgentReport(agent="standings", status="error", analysis="failed"),
|
||||||
AgentReport(agent="h2h", status="no_data", analysis="无数据"),
|
AgentReport(agent="h2h", status="no_data", analysis="无数据"),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ class TestAllExpertsFailed:
|
|||||||
header = _make_header()
|
header = _make_header()
|
||||||
|
|
||||||
# Mock run_specialists 返回全 error
|
# Mock run_specialists 返回全 error
|
||||||
async def mock_run_specialists(header, *, version, before):
|
async def mock_run_specialists(header, *, version, before, model_override=None):
|
||||||
return _all_error_reports()
|
return _all_error_reports()
|
||||||
|
|
||||||
# Mock _agent_provider
|
# Mock _agent_provider
|
||||||
@@ -131,7 +131,7 @@ class TestAllExpertsFailed:
|
|||||||
"""5 个专家全 no_data → status=degraded,不调终裁。"""
|
"""5 个专家全 no_data → status=degraded,不调终裁。"""
|
||||||
header = _make_header()
|
header = _make_header()
|
||||||
|
|
||||||
async def mock_run_specialists(header, *, version, before):
|
async def mock_run_specialists(header, *, version, before, model_override=None):
|
||||||
return _all_no_data_reports()
|
return _all_no_data_reports()
|
||||||
|
|
||||||
async def mock_agent_provider(agent_id, *, tier):
|
async def mock_agent_provider(agent_id, *, tier):
|
||||||
@@ -188,7 +188,7 @@ class TestPartialExpertsOk:
|
|||||||
"""1 个 ok + 4 个 error → status=success(走终裁)。"""
|
"""1 个 ok + 4 个 error → status=success(走终裁)。"""
|
||||||
header = _make_header()
|
header = _make_header()
|
||||||
|
|
||||||
async def mock_run_specialists(header, *, version, before):
|
async def mock_run_specialists(header, *, version, before, model_override=None):
|
||||||
return _mixed_reports()
|
return _mixed_reports()
|
||||||
|
|
||||||
async def mock_agent_provider(agent_id, *, tier):
|
async def mock_agent_provider(agent_id, *, tier):
|
||||||
@@ -255,7 +255,7 @@ class TestNoAggregatorCallOnDegraded:
|
|||||||
header = _make_header()
|
header = _make_header()
|
||||||
aggregator_called = []
|
aggregator_called = []
|
||||||
|
|
||||||
async def mock_run_specialists(header, *, version, before):
|
async def mock_run_specialists(header, *, version, before, model_override=None):
|
||||||
return _all_error_reports()
|
return _all_error_reports()
|
||||||
|
|
||||||
async def mock_agent_provider(agent_id, *, tier):
|
async def mock_agent_provider(agent_id, *, tier):
|
||||||
@@ -268,11 +268,17 @@ class TestNoAggregatorCallOnDegraded:
|
|||||||
captured_values = {}
|
captured_values = {}
|
||||||
|
|
||||||
async def mock_upsert(session, **kw):
|
async def mock_upsert(session, **kw):
|
||||||
|
# model / provider_name / mode 是 _upsert_prediction 的顶层关键字参数,
|
||||||
|
# 不在 values 字典里(见 orchestrator.py 的调用点)。原测试只取
|
||||||
|
# kw["values"],导致 model 断言永远为 None。
|
||||||
captured_values.update(kw.get("values", {}))
|
captured_values.update(kw.get("values", {}))
|
||||||
|
captured_values.update(
|
||||||
|
{k: kw.get(k) for k in ("model", "provider_name", "mode", "run_type")}
|
||||||
|
)
|
||||||
mock_pred = MagicMock()
|
mock_pred = MagicMock()
|
||||||
mock_pred.id = 1
|
mock_pred.id = 1
|
||||||
mock_pred.provider = "test"
|
mock_pred.provider = "test"
|
||||||
mock_pred.model = kw["values"].get("model")
|
mock_pred.model = kw.get("model")
|
||||||
return mock_pred
|
return mock_pred
|
||||||
|
|
||||||
class FakeUow:
|
class FakeUow:
|
||||||
|
|||||||
@@ -8,12 +8,18 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import inspect
|
import inspect
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.db.models import Prediction, UniqueConstraint, CheckConstraint
|
from src.db.models import Prediction, UniqueConstraint, CheckConstraint
|
||||||
|
|
||||||
|
# 仓库根目录下的 alembic 迁移目录 —— 相对本测试文件解析,
|
||||||
|
# 避免硬编码某台机器/CI 上的绝对路径(见 tests/test_regressions.py 的 _read 约定)。
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
MIGRATION_PATH = REPO_ROOT / "alembic" / "versions" / "0013_predictions_unique_constraint_mode_run_type.py"
|
||||||
|
|
||||||
|
|
||||||
class TestUniqueConstraint:
|
class TestUniqueConstraint:
|
||||||
"""验证唯一约束包含 mode + run_type。"""
|
"""验证唯一约束包含 mode + run_type。"""
|
||||||
@@ -71,14 +77,10 @@ class TestMigration:
|
|||||||
"""验证迁移文件存在且内容正确。"""
|
"""验证迁移文件存在且内容正确。"""
|
||||||
|
|
||||||
def test_migration_exists(self):
|
def test_migration_exists(self):
|
||||||
import os
|
assert MIGRATION_PATH.is_file(), f"迁移文件不存在: {MIGRATION_PATH}"
|
||||||
|
|
||||||
path = "/.octop/workspaces/CA7PFH/Profeto/alembic/versions/0013_predictions_unique_constraint_mode_run_type.py"
|
|
||||||
assert os.path.exists(path)
|
|
||||||
|
|
||||||
def test_migration_adds_column_and_constraint(self):
|
def test_migration_adds_column_and_constraint(self):
|
||||||
path = "/.octop/workspaces/CA7PFH/Profeto/alembic/versions/0013_predictions_unique_constraint_mode_run_type.py"
|
content = MIGRATION_PATH.read_text(encoding="utf-8")
|
||||||
content = open(path).read()
|
|
||||||
|
|
||||||
assert 'run_type' in content
|
assert 'run_type' in content
|
||||||
assert 'uq_predictions_match_provider_model_mode_run_type' in content
|
assert 'uq_predictions_match_provider_model_mode_run_type' in content
|
||||||
|
|||||||
+155
-22
@@ -14,8 +14,10 @@ from pathlib import Path
|
|||||||
|
|
||||||
SRC = Path(__file__).resolve().parent.parent / "src"
|
SRC = Path(__file__).resolve().parent.parent / "src"
|
||||||
|
|
||||||
# 切片函数会读取的关系属性 → 查询时必须 eager-load
|
# 切片函数会读取的关系属性 → 查询时必须 eager-load。
|
||||||
MATCH_RELATIONS = ("stats", "home_team", "away_team", "league")
|
# Match.stats 刻意排除: 它按设计用 lazy="select",由 selectinload(Match.stats)
|
||||||
|
# 显式预加载(见 test_stats_relationship_is_lazy_select_by_design)。
|
||||||
|
MATCH_RELATIONS = ("home_team", "away_team", "league")
|
||||||
|
|
||||||
|
|
||||||
def _read(rel: str) -> str:
|
def _read(rel: str) -> str:
|
||||||
@@ -48,27 +50,130 @@ class TestEagerLoadCoverage:
|
|||||||
assert "selectinload" in src, "backtest 未 eager-load 关系 (P0-1)"
|
assert "selectinload" in src, "backtest 未 eager-load 关系 (P0-1)"
|
||||||
|
|
||||||
def test_relationship_default_is_selectin(self):
|
def test_relationship_default_is_selectin(self):
|
||||||
"""models.py 中 Match 的高频关系应声明 lazy='selectin' 作为兜底。"""
|
"""models.py 中 Match 的高频关系应声明 lazy='selectin' 作为兜底。
|
||||||
|
|
||||||
|
这里只要求「高频一起读取」的关系(set MATCH_RELATIONS)声明 selectin。
|
||||||
|
Match.stats 刻意用 lazy="select" —— 它只在 stats 管线里按需取,不在
|
||||||
|
每个切片都读,而且它的预加载由 selectinload(Match.stats) 显式表达
|
||||||
|
(见 test_context_builder_getters_eager_load)。
|
||||||
|
"""
|
||||||
src = _read("db/models.py")
|
src = _read("db/models.py")
|
||||||
# 找到 Match 类定义段
|
# 找到 Match 类定义段
|
||||||
m = re.search(r"class Match\(Base\):.*?(?=\nclass )", src, re.S)
|
m = re.search(r"class Match\(Base\):.*?(?=\nclass )", src, re.S)
|
||||||
assert m, "Match 类未找到"
|
assert m, "Match 类未找到"
|
||||||
body = m.group(0)
|
body = m.group(0)
|
||||||
for rel in MATCH_RELATIONS:
|
for rel in MATCH_RELATIONS:
|
||||||
# 关系声明可能跨多行(stats/home_team/away_team 都是),因此按
|
# 只取 relationship(...) 调用本身的括号内内容。
|
||||||
# 「从 `rel: Mapped` 到下一个 `xxx: Mapped` 之前」整段匹配。
|
# 注意: 不能把整段(含注释)做子串匹配 —— 关系声明下方的注释里
|
||||||
|
# 恰好也写着 lazy="selectin",会导致「删掉真实 kwarg 但测试仍绿」
|
||||||
|
# 的假阳性(已用变异测试证实: 移除 league 的 lazy 后断言依旧通过)。
|
||||||
m_rel = re.search(
|
m_rel = re.search(
|
||||||
rf"^\s*{rel}: Mapped.*?(?=^\s*\w+: Mapped|\Z)", body, re.M | re.S
|
rf"^[ \t]*{rel}: Mapped.*?relationship\((.*?)\)[ \t]*$",
|
||||||
|
body, re.M | re.S,
|
||||||
)
|
)
|
||||||
assert m_rel, f"Match.{rel} 未找到"
|
assert m_rel, f"Match.{rel} 未找到 relationship(...) 声明"
|
||||||
assert 'lazy="selectin"' in m_rel.group(0), (
|
call_args = m_rel.group(1)
|
||||||
f"Match.{rel} 未声明 lazy='selectin' —— 兜底缺失 (P0-2)"
|
assert 'lazy="selectin"' in call_args, (
|
||||||
|
f"Match.{rel} 的 relationship() 未声明 lazy='selectin' —— 兜底缺失 (P0-2)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_stats_relationship_is_lazy_select_by_design(self):
|
||||||
|
"""Match.stats 刻意保持 lazy="select"(不是回归)。
|
||||||
|
|
||||||
|
它是唯一需要显式 selectinload 才预加载的关系 —— 若哪天有人把它也
|
||||||
|
改成 selectin,上面的 test_context_builder_getters_eager_load 和
|
||||||
|
bzzoiro stats 管线仍应工作,但本用例会提醒复核该设计决定。
|
||||||
|
"""
|
||||||
|
src = _read("db/models.py")
|
||||||
|
body = re.search(r"class Match\(Base\):.*?(?=\nclass )", src, re.S).group(0)
|
||||||
|
m_rel = re.search(
|
||||||
|
r"^[ \t]*stats: Mapped.*?(?=^[ \t]*\w+:[^\n]*Mapped|\Z)",
|
||||||
|
body, re.M | re.S,
|
||||||
|
)
|
||||||
|
assert m_rel, "Match.stats 未找到"
|
||||||
|
assert 'lazy="select"' in m_rel.group(0), (
|
||||||
|
"Match.stats 预期为 lazy='select'(按需加载),实际声明已变 —— 请复核设计"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestBzzoiroLineage:
|
class TestBzzoiroLineage:
|
||||||
"""P0-3: source_event_id 必须取配对的 raw,不能是循环残留变量。"""
|
"""P0-3: source_event_id 必须取配对的 raw,不能是循环残留变量。"""
|
||||||
|
|
||||||
|
# 消费循环的起始行匹配模式(见 bzzoiro.py 顶部 events 管线的内层循环)
|
||||||
|
_LOOP_PATTERN = "for nm, raw in normalized_matches"
|
||||||
|
|
||||||
|
# source_event_id 的合法赋值形状(两种,都必须取配对的 raw):
|
||||||
|
# 1) 构造新比赛: `source_event_id=_to_int_or_none(raw.get("id")),`
|
||||||
|
# 2) 回填已有比赛: `eid = _to_int_or_none(raw.get("id"))` →
|
||||||
|
# `existing_match.source_event_id = eid`
|
||||||
|
# 非法形状(即 P0-3 回归): 直接用未配对的变量给 ORM 对象赋值。
|
||||||
|
_ASSIGN_DIRECT = re.compile(
|
||||||
|
r"source_event_id\s*=\s*(?:[A-Za-z_][\w.]*\s*\(\s*)?raw(?:\.get\(|\s*\[)"
|
||||||
|
)
|
||||||
|
# 赋值给未配对的局部变量: `source_event_id = <变量>`
|
||||||
|
_ASSIGN_VIA_VAR = re.compile(
|
||||||
|
r"source_event_id\s*=\s*([A-Za-z_]\w*)\s*$"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _consume_loop_body(self, src: str) -> str:
|
||||||
|
"""截取 `for nm, raw in normalized_matches` 循环体,不含循环之后的下游代码。
|
||||||
|
|
||||||
|
原实现是 `seg = "\\n".join(lines[start:])`,一直取到文件末尾,于是把
|
||||||
|
无关的下游 stats 管线(bzzoiro.py 的 `_backfill_stats`)也扫了进来 ——
|
||||||
|
那里合法地在 ORM 对象上访问 `m.source_event_id`,导致误报 P0-3。
|
||||||
|
这里按缩进边界正确收口:循环体内每行要么是空行/注释,要么缩进严格
|
||||||
|
大于 `for` 行。
|
||||||
|
"""
|
||||||
|
lines = src.splitlines()
|
||||||
|
start = next(
|
||||||
|
(i for i, ln in enumerate(lines) if self._LOOP_PATTERN in ln), None
|
||||||
|
)
|
||||||
|
assert start is not None, f"未找到消费循环: {self._LOOP_PATTERN}"
|
||||||
|
|
||||||
|
for_indent = len(lines[start]) - len(lines[start].lstrip())
|
||||||
|
kept: list[str] = [lines[start]]
|
||||||
|
for ln in lines[start + 1:]:
|
||||||
|
stripped = ln.strip()
|
||||||
|
# 顺序要保持: 空行与注释行缩进为 0,不能拿它们做边界判断
|
||||||
|
if not stripped or stripped.startswith("#"):
|
||||||
|
continue
|
||||||
|
indent = len(ln) - len(ln.lstrip())
|
||||||
|
if indent <= for_indent:
|
||||||
|
break # 循环结束,后续属下游代码
|
||||||
|
kept.append(ln)
|
||||||
|
# 右侧剥离注释:避免 `# ... raw.get(...)` 这类注释误命中赋值正则
|
||||||
|
return "\n".join(ln.split("#", 1)[0] for ln in kept)
|
||||||
|
|
||||||
|
def _bad_assignments(self, seg: str) -> list[str]:
|
||||||
|
"""返回循环体内未取配对 raw 的 source_event_id 赋值行。"""
|
||||||
|
# 先收集"来自配对 raw"的局部变量: `eid = _to_int_or_none(raw.get("id"))`
|
||||||
|
# (不受行序影响,所以必须先建好,再判定中转赋值)
|
||||||
|
raw_vars: set[str] = set()
|
||||||
|
for ln in seg.splitlines():
|
||||||
|
m = re.match(
|
||||||
|
r"\s*([A-Za-z_]\w*)\s*=\s*.*raw(?:\.get\(|\s*\[)", ln
|
||||||
|
)
|
||||||
|
if m:
|
||||||
|
raw_vars.add(m.group(1))
|
||||||
|
|
||||||
|
bad: list[str] = []
|
||||||
|
for ln in seg.splitlines():
|
||||||
|
stripped = ln.strip()
|
||||||
|
if "source_event_id" not in stripped:
|
||||||
|
continue
|
||||||
|
# 读取判断/比较(`if x.source_event_id is None:`)不算赋值
|
||||||
|
if re.search(r"source_event_id\s*(?:is|==|!=)", stripped):
|
||||||
|
continue
|
||||||
|
if re.search(r"source_event_id\s*\.\s*\w+\s*\(", stripped):
|
||||||
|
continue # 方法调用,不是赋值
|
||||||
|
if self._ASSIGN_DIRECT.search(stripped):
|
||||||
|
continue # 直接取配对 raw
|
||||||
|
m_var = self._ASSIGN_VIA_VAR.search(stripped)
|
||||||
|
if m_var and m_var.group(1) in raw_vars:
|
||||||
|
continue # 经由已确认来自 raw 的局部变量中转
|
||||||
|
bad.append(stripped)
|
||||||
|
return bad
|
||||||
|
|
||||||
def test_normalized_matches_carries_raw(self):
|
def test_normalized_matches_carries_raw(self):
|
||||||
src = _read("data/bzzoiro.py")
|
src = _read("data/bzzoiro.py")
|
||||||
# 规范化结果必须与原始 event 成对保存
|
# 规范化结果必须与原始 event 成对保存
|
||||||
@@ -81,19 +186,47 @@ class TestBzzoiroLineage:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_no_orphan_raw_use(self):
|
def test_no_orphan_raw_use(self):
|
||||||
"""source_event_id 所在行必须在解包循环内(用缩进 + 上下文粗判)。"""
|
"""循环体内每个 source_event_id 赋值都必须取自配对的 raw(P0-3)。
|
||||||
|
|
||||||
|
用正则而不是 `raw.get(` 子串匹配:合法写法含「回填已有比赛」那条
|
||||||
|
(`eid = raw.get("id")` 之后 `existing_match.source_event_id = eid`),
|
||||||
|
它不是 `raw.get(` 同一行,但同样正确。非法写法(回归)是直接
|
||||||
|
`existing_match.source_event_id = orphan_var`。
|
||||||
|
"""
|
||||||
src = _read("data/bzzoiro.py")
|
src = _read("data/bzzoiro.py")
|
||||||
lines = src.splitlines()
|
seg = self._consume_loop_body(src)
|
||||||
# 找到 "for nm, raw in normalized_matches" 所在行号
|
bad = self._bad_assignments(seg)
|
||||||
start = next(
|
assert len(bad) == 0, (
|
||||||
(i for i, ln in enumerate(lines) if "for nm, raw in normalized_matches" in ln),
|
f"source_event_id 未使用配对的 raw (P0-3),问题行: {bad}"
|
||||||
None,
|
|
||||||
)
|
)
|
||||||
assert start is not None
|
|
||||||
# 该循环之后、下一个同/更低缩进的顶层语句之前的范围
|
def test_loop_body_scope_excludes_downstream_stats_pipeline(self):
|
||||||
seg = "\n".join(lines[start:])
|
"""作用域守卫: 截取段不能扫到循环之后的下游 stats 管线。
|
||||||
uses = [ln for ln in seg.splitlines() if "source_event_id" in ln]
|
|
||||||
assert uses, "未找到 source_event_id 赋值"
|
下游 `_backfill_stats` 里合法地在 ORM 对象上访问 `m.source_event_id`
|
||||||
assert all("raw.get(" in ln for ln in uses), (
|
(与配对 raw 无关)。若 seg 越界,test_no_orphan_raw_use 会误报。
|
||||||
"source_event_id 未使用配对的 raw (P0-3)"
|
"""
|
||||||
|
src = _read("data/bzzoiro.py")
|
||||||
|
seg = self._consume_loop_body(src)
|
||||||
|
assert "m.source_event_id" not in seg, (
|
||||||
|
"循环体截取越界,扫到了下游 stats 管线 —— 会误报 P0-3"
|
||||||
)
|
)
|
||||||
|
# 但配对使用必须仍在作用域内
|
||||||
|
assert "raw.get(" in seg, "循环体内应保留 `raw.get(...)` 的配对用法"
|
||||||
|
|
||||||
|
def test_guard_detects_orphan_variable_regression(self):
|
||||||
|
"""守卫有效性: 若 source_event_id 改成取循环外残留变量,必须被判失败。
|
||||||
|
|
||||||
|
回归保护的"元测试"——确保上面的正则在真实缺陷面前确实会红,
|
||||||
|
而不是恒真的空断言。
|
||||||
|
"""
|
||||||
|
orphan = """
|
||||||
|
for nm, raw in normalized_matches:
|
||||||
|
m = Match(
|
||||||
|
league_id=1,
|
||||||
|
source_event_id=_to_int_or_none(orphan.get("id")),
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
seg = self._consume_loop_body(orphan)
|
||||||
|
bad = self._bad_assignments(seg)
|
||||||
|
assert bad, "守卫失效: 未配对的 orphan 变量未被识别为 P0-3 回归"
|
||||||
|
|||||||
@@ -0,0 +1,506 @@
|
|||||||
|
"""回归测试: 代码评审确认的 5 个缺陷修复(R1-R5)。
|
||||||
|
|
||||||
|
R1 429 key 轮换路径调用不存在的 _km → NameError(且是凭证脱敏点)
|
||||||
|
R2 ingest_bzzoiro_standings 被截断,永不写 standings 表
|
||||||
|
R3 orchestrator 完成日志把 list 喂给 %d → logging TypeError
|
||||||
|
R4 mode="multi" 静默丢弃调用方传入的 model
|
||||||
|
R5 回测把字符串日期直接与 timestamptz 列比较
|
||||||
|
|
||||||
|
R2 采用行为测试(假 db + monkeypatch 抓取函数),其余为单元/结构断言。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
import logging
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.data.key_ring import _mask
|
||||||
|
from src.llm import backtest as bt_mod
|
||||||
|
from src.llm.agents import orchestrator as orch_mod
|
||||||
|
|
||||||
|
_REPO_ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||||
|
_BZZOIRO_SRC = _REPO_ROOT / "src" / "data" / "bzzoiro.py"
|
||||||
|
|
||||||
|
# 在导入期就抓取真实的 _agent_provider。
|
||||||
|
# 原因: tests/test_multi_agent_cutoff.py:52/87/117 会直接
|
||||||
|
# orch._agent_provider = lambda agent_id, **kw: MagicMock(model="test")
|
||||||
|
# 且不做清理(既有测试,本次任务不允许改动),导致模块属性在整套测试跑完后
|
||||||
|
# 被永久替换成同步 lambda。导入期快照可以规避这种跨测试污染。
|
||||||
|
_REAL_AGENT_PROVIDER = orch_mod._agent_provider
|
||||||
|
|
||||||
|
|
||||||
|
def _bzzoiro_source() -> str:
|
||||||
|
return _BZZOIRO_SRC.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# R1 — _km → _mask
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class TestR1KeyMasking:
|
||||||
|
def test_mask_is_importable_from_bzzoiro(self):
|
||||||
|
"""修复点:bzzoiro 通过 key_ring 复用它,而不是本地重实现。"""
|
||||||
|
import src.data.bzzoiro as bz
|
||||||
|
|
||||||
|
assert bz._mask("abcd1234efgh5678") == "abcd...5678"
|
||||||
|
|
||||||
|
def test_mask_long_key_shows_head_and_tail(self):
|
||||||
|
assert _mask("abcd1234efgh5678") == "abcd...5678"
|
||||||
|
|
||||||
|
def test_mask_short_key_hides_middle(self):
|
||||||
|
assert _mask("short") == "sh***"
|
||||||
|
|
||||||
|
def test_no_km_call_remains_in_bzzoiro_source(self):
|
||||||
|
"""源码守卫: _km 在整个代码库不存在,这行一旦执行必抛 NameError。
|
||||||
|
|
||||||
|
这是 NameError 类缺陷(静态即可判定),且位于凭证脱敏日志行上,
|
||||||
|
因此用源码文本守卫是恰当的,而不是只测运行时路径。
|
||||||
|
"""
|
||||||
|
assert "_km(" not in _bzzoiro_source()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# R2 — 积分榜 upsert(行为测试)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class _FakeScalars:
|
||||||
|
def __init__(self, items):
|
||||||
|
self._items = list(items)
|
||||||
|
|
||||||
|
def all(self):
|
||||||
|
return list(self._items)
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
return iter(self._items)
|
||||||
|
|
||||||
|
def scalar_one_or_none(self):
|
||||||
|
return self._items[0] if self._items else None
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResult:
|
||||||
|
def __init__(self, items):
|
||||||
|
self._items = list(items)
|
||||||
|
|
||||||
|
def scalars(self):
|
||||||
|
return _FakeScalars(self._items)
|
||||||
|
|
||||||
|
def scalar_one_or_none(self):
|
||||||
|
return self._items[0] if self._items else None
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeDb:
|
||||||
|
"""极简假 db: 记录 add/flush,按队列返回预置查询结果。
|
||||||
|
|
||||||
|
只实现 ingest_bzzoiro_standings 真正用到的部分:
|
||||||
|
- execute(...) → 依次弹出 _results 里的结果
|
||||||
|
- add(obj) → 记录
|
||||||
|
- flush() → 给尚无 id 的对象补一个自增 id(模拟 DB 回填主键)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, results=None):
|
||||||
|
self._results = list(results or [])
|
||||||
|
self.added: list = []
|
||||||
|
self.flush_count = 0
|
||||||
|
self._next_id = 1000
|
||||||
|
|
||||||
|
async def execute(self, _stmt):
|
||||||
|
if self._results:
|
||||||
|
return self._results.pop(0)
|
||||||
|
return _FakeResult([])
|
||||||
|
|
||||||
|
def add(self, obj):
|
||||||
|
self.added.append(obj)
|
||||||
|
|
||||||
|
async def flush(self):
|
||||||
|
self.flush_count += 1
|
||||||
|
for obj in self.added:
|
||||||
|
if getattr(obj, "id", None) is None:
|
||||||
|
self._next_id += 1
|
||||||
|
obj.id = self._next_id
|
||||||
|
|
||||||
|
|
||||||
|
async def test_r2_standings_actually_upserts(monkeypatch):
|
||||||
|
"""行为测试: 喂一份积分榜 payload,断言真的构造了 Standing 且计数 > 0。"""
|
||||||
|
import src.data.bzzoiro as bz
|
||||||
|
from src.db.models import League, Standing, Team
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"season": {"start_date": "2025-08-01", "end_date": "2026-05-31"},
|
||||||
|
"standings": [
|
||||||
|
{
|
||||||
|
"position": 1, "team_name": "Arsenal FC",
|
||||||
|
"played": 10, "won": 8, "drawn": 1, "lost": 1,
|
||||||
|
"gf": 22, "ga": 8, "gd": 14, "pts": 25,
|
||||||
|
"xgf": 18.5, "xga": 9.1, "form": "WWDLW",
|
||||||
|
"zone": {"key": "champions_league", "label": "Champions League"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"position": 2, "team_name": "Chelsea FC",
|
||||||
|
"played": 10, "won": 6, "drawn": 2, "lost": 2,
|
||||||
|
"gf": 18, "ga": 12, "gd": 6, "pts": 20,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _fake_fetch(league_code, season=None):
|
||||||
|
return payload
|
||||||
|
|
||||||
|
monkeypatch.setattr(bz, "fetch_bzzoiro_standings", _fake_fetch, raising=True)
|
||||||
|
|
||||||
|
# 查询顺序: League 命中(避免建联赛) → Team 预载(空) → 每行 Standing(未命中)
|
||||||
|
league = League(code="EPL", name="Premier League", country="England")
|
||||||
|
league.id = 42
|
||||||
|
db = _FakeDb(results=[_FakeResult([league]), _FakeResult([])])
|
||||||
|
|
||||||
|
result = await bz.ingest_bzzoiro_standings(db, leagues=["EPL"])
|
||||||
|
|
||||||
|
assert result["errors"] == []
|
||||||
|
assert result["total_upserted"] == 2, result
|
||||||
|
assert result["leagues"]["EPL"]["rows"] == 2
|
||||||
|
assert result["leagues"]["EPL"]["upserted"] == 2
|
||||||
|
# 两支球队都是新建的
|
||||||
|
assert result["leagues"]["EPL"]["teams_created"] == 2
|
||||||
|
|
||||||
|
standings = [o for o in db.added if isinstance(o, Standing)]
|
||||||
|
assert len(standings) == 2, "应真的构造 Standing 行"
|
||||||
|
assert all(isinstance(o, (Standing, Team)) for o in db.added)
|
||||||
|
|
||||||
|
first = standings[0]
|
||||||
|
assert first.league_id == 42
|
||||||
|
assert first.season == "2025-2026" # 8 月起 → 跨年标签
|
||||||
|
assert first.position == 1
|
||||||
|
assert first.points == 25
|
||||||
|
assert first.xg_for == 18.5
|
||||||
|
assert first.zone == "Champions League" # 优先取 label
|
||||||
|
|
||||||
|
|
||||||
|
async def test_r2_standings_upsert_updates_existing(monkeypatch):
|
||||||
|
"""行为测试: 已存在同 (league, season, team) 时应就地更新而非新增。"""
|
||||||
|
import src.data.bzzoiro as bz
|
||||||
|
from src.db.models import League, Standing
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"season": {"start_date": "2025-08-01", "end_date": "2026-05-31"},
|
||||||
|
"standings": [{"position": 1, "team_name": "Arsenal FC", "pts": 30}],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _fake_fetch(league_code, season=None):
|
||||||
|
return payload
|
||||||
|
|
||||||
|
monkeypatch.setattr(bz, "fetch_bzzoiro_standings", _fake_fetch, raising=True)
|
||||||
|
|
||||||
|
league = League(code="EPL", name="Premier League", country="England")
|
||||||
|
league.id = 42
|
||||||
|
team = __import__("src.db.models", fromlist=["Team"]).Team(name="Arsenal FC", name_zh="阿森纳")
|
||||||
|
team.id = 7
|
||||||
|
|
||||||
|
existing = Standing(league_id=42, season="2025-2026", team_id=7, position=9)
|
||||||
|
existing.points = 1
|
||||||
|
|
||||||
|
# 查询顺序: League → Team 预载(命中) → Standing 查询(命中已有行)
|
||||||
|
db = _FakeDb(results=[_FakeResult([league]), _FakeResult([team]), _FakeResult([existing])])
|
||||||
|
|
||||||
|
result = await bz.ingest_bzzoiro_standings(db, leagues=["EPL"])
|
||||||
|
|
||||||
|
assert result["total_upserted"] == 1
|
||||||
|
assert existing.points == 30, "已有行应被就地更新"
|
||||||
|
assert result["leagues"]["EPL"]["teams_created"] == 0
|
||||||
|
# 不应新增 Standing(只有 league/team 层面的 add)
|
||||||
|
assert not [o for o in db.added if isinstance(o, Standing)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_r2_source_contains_real_upsert_loop():
|
||||||
|
"""结构断言(行为测试之外的兜底): 确认函数未被截断。"""
|
||||||
|
import src.data.bzzoiro as bz
|
||||||
|
|
||||||
|
src = inspect.getsource(bz.ingest_bzzoiro_standings)
|
||||||
|
assert "total_upserted" in src
|
||||||
|
assert 'result["total_upserted"] +=' in src, "total_upserted 必须真的被累加"
|
||||||
|
assert "Standing(" in src, "必须真的构造 Standing"
|
||||||
|
assert "select(Standing)" in src, "必须查询已有快照以决定 insert/update"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# R3 — logging 参数类型
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def test_r3_completion_log_record_formats_without_raising():
|
||||||
|
"""%d 占位符拿到 list 时 logging 会抛 TypeError;修复后应为计数。"""
|
||||||
|
fmt = (
|
||||||
|
"预测完成 match=%s mode=%s status=%s pred=%s:%s (%s) latency=%sms, "
|
||||||
|
"experts=%d/%d, prediction_id=%s"
|
||||||
|
)
|
||||||
|
ok_reports = ["a", "b", "c"]
|
||||||
|
reports = ["a", "b", "c", "d", "e"]
|
||||||
|
|
||||||
|
record = logging.LogRecord(
|
||||||
|
"src.llm.agents.orchestrator", logging.INFO, __file__, 1, fmt,
|
||||||
|
(999, "multi", "success", 2, 1, "1", 100, len(ok_reports), len(reports), 7),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
msg = record.getMessage() # 修复前此处抛 TypeError
|
||||||
|
assert "experts=3/5" in msg
|
||||||
|
|
||||||
|
# 反证: 原缺陷写法(直接传 list)确实会炸,确保这条测试真的有鉴别力
|
||||||
|
bad = logging.LogRecord(
|
||||||
|
"x", logging.INFO, __file__, 1, fmt,
|
||||||
|
(999, "multi", "success", 2, 1, "1", 100, ok_reports, len(reports), 7),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
bad.getMessage()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# R4 — multi 模式透传 model
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
_ORCH_SRC_PATH = _REPO_ROOT / "src" / "llm" / "agents" / "orchestrator.py"
|
||||||
|
|
||||||
|
|
||||||
|
def _orchestrator_source() -> str:
|
||||||
|
"""直接读源码,而不是 inspect.getsource(模块属性)。
|
||||||
|
|
||||||
|
既有测试(如 test_agent_weights_persist.py)会在运行期把
|
||||||
|
orch_mod._agent_provider 换成 lambda/MagicMock,导致 inspect.getsource
|
||||||
|
拿到的是 mock 的定义。本文件的断言针对真实源码,故从磁盘读取。
|
||||||
|
"""
|
||||||
|
return _ORCH_SRC_PATH.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_r4_predict_match_multi_accepts_model():
|
||||||
|
"""predict_match_multi 必须接受 model 且默认 None(向后兼容既有调用)。"""
|
||||||
|
src_fn = _orchestrator_source().split("async def predict_match_multi(", 1)[1]
|
||||||
|
header = src_fn.split(") -> MultiPredictResult:", 1)[0]
|
||||||
|
assert "model: str | None = None" in header, header
|
||||||
|
|
||||||
|
|
||||||
|
def test_r4_agent_provider_accepts_model_override():
|
||||||
|
src_fn = _orchestrator_source().split("async def _agent_provider(", 1)[1]
|
||||||
|
header = src_fn.split(") -> LLMProvider:", 1)[0]
|
||||||
|
assert "model_override: str | None = None" in header, header
|
||||||
|
|
||||||
|
|
||||||
|
async def test_r4_dispatch_forwards_model_to_multi(monkeypatch):
|
||||||
|
"""行为测试: predict_match(mode=multi, model=...) 必须把 model 送到 multi 路径。"""
|
||||||
|
from src.llm import predict as predict_mod
|
||||||
|
|
||||||
|
captured: dict = {}
|
||||||
|
|
||||||
|
async def _fake_multi(match_id, **kwargs):
|
||||||
|
captured["match_id"] = match_id
|
||||||
|
captured.update(kwargs)
|
||||||
|
return "SENTINEL"
|
||||||
|
|
||||||
|
# predict.py:188 是函数内 `from ... import`,import 发生在调用时,
|
||||||
|
# 所以必须打在 orchestrator 模块的属性上。
|
||||||
|
monkeypatch.setattr(orch_mod, "predict_match_multi", _fake_multi, raising=True)
|
||||||
|
|
||||||
|
out = await predict_mod.predict_match(999, model="my-model-x", mode="multi")
|
||||||
|
|
||||||
|
assert out == "SENTINEL"
|
||||||
|
assert captured["model"] == "my-model-x"
|
||||||
|
assert captured["match_id"] == 999
|
||||||
|
|
||||||
|
|
||||||
|
async def test_r4_specialist_provider_honors_model_override(monkeypatch):
|
||||||
|
"""行为测试: model_override 应覆盖 agent 级/层级默认模型。"""
|
||||||
|
from src.llm.provider import LLMProvider
|
||||||
|
import src.llm.agents.orchestrator as real_orch
|
||||||
|
|
||||||
|
async def _fake_default():
|
||||||
|
return LLMProvider(api_key="k", base_url="http://x", model="default-model", timeout=1.0)
|
||||||
|
|
||||||
|
async def _fake_runtime(key):
|
||||||
|
if key.endswith("_MODEL"):
|
||||||
|
return "agent-level-model"
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(real_orch, "get_default_provider", _fake_default, raising=True)
|
||||||
|
monkeypatch.setattr(real_orch, "get_runtime_value", _fake_runtime, raising=True)
|
||||||
|
monkeypatch.setattr(real_orch, "settings", _settings_with_specialist_model(), raising=True)
|
||||||
|
monkeypatch.setattr(real_orch, "_AGENT_PROVIDER_CACHE", {}, raising=True)
|
||||||
|
|
||||||
|
overridden = await _REAL_AGENT_PROVIDER("form", tier="specialist", model_override="OVERRIDE")
|
||||||
|
assert overridden.model == "OVERRIDE"
|
||||||
|
|
||||||
|
# 不带 override 时仍走原优先级(agent 级运行时配置)
|
||||||
|
real_orch._AGENT_PROVIDER_CACHE.clear()
|
||||||
|
normal = await _REAL_AGENT_PROVIDER("form", tier="specialist")
|
||||||
|
assert normal.model == "agent-level-model"
|
||||||
|
|
||||||
|
|
||||||
|
def _settings_with_specialist_model():
|
||||||
|
class _S:
|
||||||
|
LLM_SPECIALIST_MODEL = "tier-specialist-model"
|
||||||
|
LLM_AGGREGATOR_MODEL = "tier-aggregator-model"
|
||||||
|
|
||||||
|
return _S()
|
||||||
|
|
||||||
|
|
||||||
|
def test_r4_override_does_not_pollute_cache():
|
||||||
|
"""override 结果不得写入 60s provider 缓存(否则会串味给普通调用)。"""
|
||||||
|
src = _orchestrator_source().split("async def _agent_provider(", 1)[1]
|
||||||
|
src = src.split("async def run_specialists(", 1)[0]
|
||||||
|
assert "if model_override is None:" in src
|
||||||
|
assert "_AGENT_PROVIDER_CACHE[cache_key]" in src
|
||||||
|
|
||||||
|
|
||||||
|
async def test_r4_dispatch_passes_override_to_specialists(monkeypatch):
|
||||||
|
"""行为测试: predict_match_multi 必须把 model 作为**形参**传给 run_specialists。
|
||||||
|
|
||||||
|
早期实现用模块级变量 _ACTIVE_MODEL_OVERRIDE 中转,但 backtest 会
|
||||||
|
asyncio.gather 并发 8 场预测(backtest.py Semaphore(8)),全局变量会被
|
||||||
|
并发调用互相覆盖 → A 场预测用上 B 场的模型。故此处断言「形参传递」,
|
||||||
|
并显式断言该模块级变量已不存在。
|
||||||
|
|
||||||
|
参照 tests/test_multi_agent_degraded.py 的 stub 方式,避免触碰真实 DB。
|
||||||
|
"""
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from src.db.unit_of_work import get_uow
|
||||||
|
|
||||||
|
seen: dict = {}
|
||||||
|
|
||||||
|
async def _fake_header(match_id):
|
||||||
|
h = MagicMock()
|
||||||
|
h.match_id = match_id
|
||||||
|
h.match_dt = None
|
||||||
|
return h
|
||||||
|
|
||||||
|
async def _fake_specialists(header, *, version, before, model_override=None):
|
||||||
|
seen["override_during_run"] = model_override
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def _fake_upsert(session, **kw):
|
||||||
|
p = MagicMock()
|
||||||
|
p.id = 1
|
||||||
|
p.provider = "test"
|
||||||
|
p.model = kw.get("model")
|
||||||
|
p.prompt_version = "v1"
|
||||||
|
p.pred_home_goals = None
|
||||||
|
p.pred_away_goals = None
|
||||||
|
p.pred_1x2 = None
|
||||||
|
p.alt_pred_home_goals = None
|
||||||
|
p.alt_pred_away_goals = None
|
||||||
|
p.subjective_confidence = None
|
||||||
|
p.reasoning = ""
|
||||||
|
p.agent_outputs = []
|
||||||
|
p.agent_weights = {}
|
||||||
|
p.prompt_tokens = 0
|
||||||
|
p.completion_tokens = 0
|
||||||
|
return p
|
||||||
|
|
||||||
|
class _FakeUow:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *a):
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def get(self, cls, id):
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
monkeypatch.setattr(orch_mod, "load_match_header", _fake_header, raising=True)
|
||||||
|
monkeypatch.setattr(orch_mod, "run_specialists", _fake_specialists, raising=True)
|
||||||
|
monkeypatch.setattr(orch_mod, "_upsert_prediction", _fake_upsert, raising=True)
|
||||||
|
monkeypatch.setattr(orch_mod, "get_uow", _FakeUow, raising=True)
|
||||||
|
|
||||||
|
assert get_uow is not None # 确保 import 生效,session 未被真实打开
|
||||||
|
|
||||||
|
await orch_mod.predict_match_multi(999, model="OVERRIDE-X")
|
||||||
|
|
||||||
|
assert seen["override_during_run"] == "OVERRIDE-X", (
|
||||||
|
"model 未作为形参传给 run_specialists"
|
||||||
|
)
|
||||||
|
# 回归守卫: 模块级中转变量必须不存在(并发下会产生模型串味)
|
||||||
|
assert not hasattr(orch_mod, "_ACTIVE_MODEL_OVERRIDE"), (
|
||||||
|
"不应再用模块级 _ACTIVE_MODEL_OVERRIDE 中转 model: backtest 并发 8 场预测时"
|
||||||
|
"会互相覆盖,导致模型串味"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_r4_run_specialists_accepts_model_override_parameter():
|
||||||
|
"""run_specialists 必须显式接收 model_override 形参(而非读全局)。"""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
sig = inspect.signature(orch_mod.run_specialists)
|
||||||
|
assert "model_override" in sig.parameters, (
|
||||||
|
"run_specialists 缺少 model_override 形参 —— 并发场景下模型会串味"
|
||||||
|
)
|
||||||
|
assert sig.parameters["model_override"].default is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_r4_no_module_level_model_override_global():
|
||||||
|
"""并发安全守卫: orchestrator 不得用模块级变量中转 model 覆盖。
|
||||||
|
|
||||||
|
backtest 会 asyncio.gather 并发 8 场预测(backtest.py 的 Semaphore(8)),
|
||||||
|
模块级变量会被并发调用互相覆盖 → A 场的预测用上 B 场的模型(模型串味)。
|
||||||
|
正确做法是把 model 作为形参一路下传。
|
||||||
|
|
||||||
|
说明: 这里用源码级断言而非并发行为测试 —— 真实 run_specialists 会调用
|
||||||
|
数据库(_agent_provider -> load_match_header),在无 DB 的测试环境下
|
||||||
|
无法稳定执行,写出来的并发测试会是 flaky 的假证据(已实测确认)。
|
||||||
|
形参方案与全局方案的判别点清晰且可直接观测,故用源码守卫。
|
||||||
|
"""
|
||||||
|
src = _orchestrator_source()
|
||||||
|
|
||||||
|
# 1) 不得存在模块级覆盖变量
|
||||||
|
assert "_ACTIVE_MODEL_OVERRIDE" not in src, (
|
||||||
|
"orchestrator 又引入了模块级 model 覆盖变量 —— 并发预测会模型串味"
|
||||||
|
)
|
||||||
|
# 2) 不得有 `global` 声明去写模型覆盖
|
||||||
|
assert not re.search(r"^\s*global\s+.*MODEL", src, re.M), (
|
||||||
|
"orchestrator 使用 global 声明中转模型覆盖 —— 并发下不安全"
|
||||||
|
)
|
||||||
|
# 3) model_override 必须作为实参出现在 run_specialists 调用里
|
||||||
|
call = re.search(
|
||||||
|
r"await run_specialists\((.*?)\)", src, re.S
|
||||||
|
)
|
||||||
|
assert call, "未找到 run_specialists 调用点"
|
||||||
|
assert "model_override=" in call.group(1), (
|
||||||
|
"run_specialists 调用点未显式传 model_override —— "
|
||||||
|
"model 可能又走回隐式中转,并发下会串味"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# R5 — 回测日期解析
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class TestR5DateBound:
|
||||||
|
def test_plain_date_start_is_start_of_day_utc(self):
|
||||||
|
dt = bt_mod._parse_date_bound("2026-01-01", end_of_day=False)
|
||||||
|
assert dt is not None
|
||||||
|
assert dt.tzinfo is not None
|
||||||
|
assert (dt.year, dt.month, dt.day) == (2026, 1, 1)
|
||||||
|
assert (dt.hour, dt.minute, dt.second) == (0, 0, 0)
|
||||||
|
|
||||||
|
def test_plain_date_end_is_inclusive_end_of_day(self):
|
||||||
|
"""闭区间: 结束日必须取当天末刻,否则最后一天被静默排除。"""
|
||||||
|
dt = bt_mod._parse_date_bound("2026-01-01", end_of_day=True)
|
||||||
|
assert dt is not None
|
||||||
|
assert (dt.hour, dt.minute, dt.second) == (23, 59, 59)
|
||||||
|
assert dt.microsecond == 999999
|
||||||
|
|
||||||
|
def test_full_iso_string_is_parsed(self):
|
||||||
|
dt = bt_mod._parse_date_bound("2026-01-01T12:30:00+08:00", end_of_day=False)
|
||||||
|
assert dt is not None
|
||||||
|
assert dt.utcoffset() is not None
|
||||||
|
|
||||||
|
def test_none_returns_none(self):
|
||||||
|
assert bt_mod._parse_date_bound(None, end_of_day=False) is None
|
||||||
|
assert bt_mod._parse_date_bound(None, end_of_day=True) is None
|
||||||
|
|
||||||
|
def test_datetime_passthrough(self):
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
given = datetime(2026, 5, 5, 6, 0, tzinfo=timezone.utc)
|
||||||
|
assert bt_mod._parse_date_bound(given, end_of_day=False) == given
|
||||||
|
|
||||||
|
def test_invalid_input_raises_value_error(self):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
bt_mod._parse_date_bound("not-a-date", end_of_day=False)
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
"""C1 回归测试: 定时任务调度器「静默失效」缺陷。
|
||||||
|
|
||||||
|
原始缺陷(全量审查 C1):
|
||||||
|
1. `Scheduler.register()` 的第 2 参是 cron 表达式,但 app.py:52 /
|
||||||
|
schedules.py:95,119 三处调用传的都是任务类型字符串("events" 等)。
|
||||||
|
`croniter("events")` 抛异常被 `_calc_next` 的 except 吞掉 →
|
||||||
|
next_run 永远为 None → 所有定时任务从不触发且无任何报错。
|
||||||
|
2. 运行期通过 API 新建的任务只进了 _tasks 字典,从未
|
||||||
|
asyncio.create_task 启动 _run_loop(只有 Scheduler.start() 会建循环)。
|
||||||
|
|
||||||
|
这些用例不依赖数据库,直接对调度器类做行为断言。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.core.scheduler import ScheduledTask, Scheduler
|
||||||
|
|
||||||
|
|
||||||
|
class TestCronValidation:
|
||||||
|
"""非法 cron 必须显式报错,不能静默变成永不触发。"""
|
||||||
|
|
||||||
|
def test_nonevent_cron_raises(self):
|
||||||
|
"""把任务类型字符串当 cron 传(原缺陷) → 构造时立即抛 ValueError。"""
|
||||||
|
async def _noop() -> None: ...
|
||||||
|
with pytest.raises(ValueError, match="cron"):
|
||||||
|
ScheduledTask("daily-events", "events", _noop)
|
||||||
|
|
||||||
|
def test_valid_cron_accepted(self):
|
||||||
|
"""合法 cron 正常构造,且 next_run 被计算出来(非 None)。"""
|
||||||
|
async def _noop() -> None: ...
|
||||||
|
task = ScheduledTask("daily-events", "0 8 * * *", _noop)
|
||||||
|
assert task.next_run is not None
|
||||||
|
|
||||||
|
def test_next_run_is_in_future(self):
|
||||||
|
"""next_run 必须落在未来,否则 _run_loop 会立刻误触发。"""
|
||||||
|
from datetime import datetime
|
||||||
|
async def _noop() -> None: ...
|
||||||
|
task = ScheduledTask("t", "*/30 * * * *", _noop)
|
||||||
|
assert task.next_run > datetime.now()
|
||||||
|
|
||||||
|
def test_croniter_receives_called_datetime(self):
|
||||||
|
"""回归: `croniter(expr, datetime.now)`(未调用)会抛
|
||||||
|
TypeError: 'builtin_function_or_method' object cannot be interpreted
|
||||||
|
as an integer —— 这是比「传错参数」更深一层的静默失效根源。
|
||||||
|
断言 next_run 是真实 datetime,而非 None。"""
|
||||||
|
from datetime import datetime
|
||||||
|
async def _noop() -> None: ...
|
||||||
|
task = ScheduledTask("t", "0 8 * * *", _noop)
|
||||||
|
assert isinstance(task.next_run, datetime), (
|
||||||
|
f"next_run 应为 datetime,实际 {task.next_run!r} —— "
|
||||||
|
"croniter 的 start_time 未正确传入"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunLoopActuallyFires:
|
||||||
|
"""注册后任务必须真的在到期时被执行(端到端行为,不 mock 内部)。"""
|
||||||
|
|
||||||
|
async def test_register_then_start_runs_task(self):
|
||||||
|
"""cron 到点后任务函数被调用一次。用 1 秒粒度的 * * * * * 加速验证。"""
|
||||||
|
calls: list[str] = []
|
||||||
|
|
||||||
|
async def _job() -> None:
|
||||||
|
calls.append("ran")
|
||||||
|
|
||||||
|
sched = Scheduler()
|
||||||
|
sched.register("t1", "* * * * *", _job, enabled=True)
|
||||||
|
await sched.start()
|
||||||
|
try:
|
||||||
|
# _run_loop 用 min(wait, 60) 分段睡;下一分钟边界最多 60 秒。
|
||||||
|
# 为让测试可跑,直接把 next_run 拨到过去,触发一次执行。
|
||||||
|
task = sched.get("t1")
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
task.next_run = datetime.now() - timedelta(seconds=1)
|
||||||
|
for _ in range(40):
|
||||||
|
if calls:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
finally:
|
||||||
|
await sched.stop()
|
||||||
|
assert calls == ["ran"], "注册并 start 后,到期任务未被执行"
|
||||||
|
|
||||||
|
async def test_register_after_start_also_runs(self):
|
||||||
|
"""运行期(已 start 之后)新 register 的任务也必须被启动(原缺陷 2)。"""
|
||||||
|
calls: list[str] = []
|
||||||
|
|
||||||
|
async def _job() -> None:
|
||||||
|
calls.append("late")
|
||||||
|
|
||||||
|
sched = Scheduler()
|
||||||
|
await sched.start()
|
||||||
|
try:
|
||||||
|
sched.register("late-task", "* * * * *", _job, enabled=True)
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
sched.get("late-task").next_run = datetime.now() - timedelta(seconds=1)
|
||||||
|
for _ in range(40):
|
||||||
|
if calls:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
finally:
|
||||||
|
await sched.stop()
|
||||||
|
assert calls == ["late"], "start 之后注册的任务未被启动循环"
|
||||||
|
|
||||||
|
async def test_disabled_task_does_not_run(self):
|
||||||
|
"""enabled=False 的任务不执行。"""
|
||||||
|
calls: list[str] = []
|
||||||
|
|
||||||
|
async def _job() -> None:
|
||||||
|
calls.append("nope")
|
||||||
|
|
||||||
|
sched = Scheduler()
|
||||||
|
sched.register("off", "* * * * *", _job, enabled=False)
|
||||||
|
await sched.start()
|
||||||
|
try:
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
sched.get("off").next_run = datetime.now() - timedelta(seconds=1)
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
finally:
|
||||||
|
await sched.stop()
|
||||||
|
assert calls == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestTaskFailureIsolation:
|
||||||
|
"""单个任务抛异常不能杀掉循环(否则一次失败永久停摆)。"""
|
||||||
|
|
||||||
|
async def test_exception_does_not_kill_loop(self):
|
||||||
|
"""任务抛异常后,循环仍存活并能在下一次到期时继续执行。"""
|
||||||
|
runs: list[int] = []
|
||||||
|
|
||||||
|
async def _boom() -> None:
|
||||||
|
runs.append(1)
|
||||||
|
if len(runs) == 1:
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
|
||||||
|
sched = Scheduler()
|
||||||
|
sched.register("flaky", "* * * * *", _boom, enabled=True)
|
||||||
|
await sched.start()
|
||||||
|
try:
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
task = sched.get("flaky")
|
||||||
|
for _ in range(40):
|
||||||
|
if len(runs) >= 2:
|
||||||
|
break
|
||||||
|
task.next_run = datetime.now() - timedelta(seconds=1)
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
assert len(runs) >= 2, "任务首次抛异常后循环未继续"
|
||||||
|
assert task._task is not None and not task._task.done(), (
|
||||||
|
"循环在任务抛异常后已终止"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await sched.stop()
|
||||||
|
|
||||||
|
async def test_next_run_change_takes_effect_promptly(self):
|
||||||
|
"""运行期把 next_run 改到过去,循环须在 SLEEP_TICK 内感知并触发。
|
||||||
|
|
||||||
|
回归: 原实现 sleep(min(wait_seconds, 60)),当 wait_seconds<60 时
|
||||||
|
会一次性睡满 wait_seconds,导致运行期通过 API 更新 cron 后
|
||||||
|
最长 60s 不生效。
|
||||||
|
"""
|
||||||
|
calls: list[str] = []
|
||||||
|
|
||||||
|
async def _job() -> None:
|
||||||
|
calls.append("ran")
|
||||||
|
|
||||||
|
import src.core.scheduler as sched_mod
|
||||||
|
old_tick = sched_mod.ScheduledTask.SLEEP_TICK
|
||||||
|
sched_mod.ScheduledTask.SLEEP_TICK = 0.05
|
||||||
|
try:
|
||||||
|
sched = Scheduler()
|
||||||
|
sched.register("tick", "* * * * *", _job, enabled=True)
|
||||||
|
await sched.start()
|
||||||
|
try:
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
# 先等循环进入 sleep(wait 接近 60s 的最坏情况)
|
||||||
|
await asyncio.sleep(0.15)
|
||||||
|
sched.get("tick").next_run = datetime.now() - timedelta(seconds=1)
|
||||||
|
# 短 tick 应在 ~0.15s 内感知;给 1s 容差
|
||||||
|
for _ in range(40):
|
||||||
|
if calls:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
finally:
|
||||||
|
await sched.stop()
|
||||||
|
finally:
|
||||||
|
sched_mod.ScheduledTask.SLEEP_TICK = old_tick
|
||||||
|
assert calls == ["ran"], (
|
||||||
|
"next_run 变更后循环未在 tick 内响应(疑似一次性睡满 wait_seconds)"
|
||||||
|
)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""前端 UI 审查清单(逐页排查结果)。
|
||||||
|
|
||||||
|
发现的问题按影响排序,修复按任务范围执行。
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ── 高影响(必修) ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
ISSUES_HIGH = [
|
||||||
|
# 1. 比赛列表筛选栏:小屏4组筛选换行混乱,日期 input 与文字标签不对齐
|
||||||
|
# 2. 比赛行:主/客队名在小屏截断过重,比分列固定 w-14 太窄
|
||||||
|
# 3. 预测弹窗:小屏内容溢出视口(无 max-height + overflow-y)
|
||||||
|
# 4. 弹窗关闭按钮 h-7 w-7 触控目标太小(应 ≥44px)
|
||||||
|
# 5. sticky 日期分组头 z-10 与弹窗 z-50 冲突(弹窗打开时分组头覆盖其上)
|
||||||
|
# 6. 预测按钮小屏 min-h-[44px] 但桌面 btn-sm min-h-[36px] 仍偏小
|
||||||
|
# 7. "载入更多"按钮在小屏不够醒目,用户不知道还有更多数据
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── 中等(应修) ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
ISSUES_MEDIUM = [
|
||||||
|
# 8. 空态不统一:有的用 EmptyState,有的用 <p>,有的只有 "暂无"
|
||||||
|
# 9. 主页 error banner 与 admin Alert 组件重复实现,样式不同
|
||||||
|
# 10. Dashboard 空态文案("请先到数据采集导入")没有链接到采集页
|
||||||
|
# 11. 侧边栏小屏汉堡菜单图标和文字重叠风险
|
||||||
|
# 12. 预测结果弹窗的 reasoning 文字没有限制最大高度,长文撑满视口
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── 低(可选) ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
ISSUES_LOW = [
|
||||||
|
# 13. "管理后台"链接的 icon ⚠ 和 nav 图标风格不一致
|
||||||
|
# 14. footer 文字在小屏可能被弹窗遮挡(z-index 层级)
|
||||||
|
# 15. 回测结果导出 CSV 按钮在移动端显示位置不明确
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user