Author SHA1 Message Date
shangfangjian 2190ea2af6 feat(ui): 编辑排版三件套与信息效率升级
- 报头升格:刊名 60px,Oxford 粗细双线,期号行(当年第几天)+体育版位
- 预测比分:主客队名纵排(writing-mode vertical-rl),右上「终裁」红印
- 五路专家倾向条:home_edge 分歧可视化,红=利主/墨=利客,条高=强度
- 强调色纪律:tab 激活态与后台导航选中改墨色,红只留 live/终裁/印章/错误
- 数字字形:serif 栈 Noto Serif SC 提到 Georgia 之前(直线数字),斜体只留给刊名(.wordmark)
- 赛程行悬停左缘红书签;空态改双线「启事」框
2026-09-18 11:24:19 +08:00
shangfangjian 1a37c75907 fix(a11y): ink-400 对比度达标,接入 Noto Serif SC webfont,补纸张噪点与入场编排
- ink-400 #9C9587(2.90:1)→ #756F61(paper-50 4.87:1 / paper-100 4.55:1),正文级小字全部达标
- serif 栈插入 Noto Serif SC(fontsource 子集加载),Windows 不再跌进 SimSun
- 首页加 feTurbulence 纸张噪点层(.grain);报头/赛程行/预测版 rise-in 错峰入场(60ms 步进)
- reduce 动效用户:隐藏态与动画同在 no-preference 块,不会白屏
- Switch 提到模块顶层(修 render 重建丢焦点);a→Link(SPA 内链);h3→h2/h4→h3 修标题层级
- 按钮/输入热区抬高(btn 40px / btn-sm 32px / field py-2);index.css 硬编码色值改 :root 令牌
2026-09-18 11:04:46 +08:00
134 changed files with 2264 additions and 17826 deletions
-37
View File
@@ -1,37 +0,0 @@
# Python
__pycache__
*.py[cod]
*$py.class
*.egg-info
dist
build
# 虚拟环境
.venv
venv
# 环境配置(含敏感信息,绝不能打入镜像)
.env
.env.*
# 测试与工具
tests
.pytest_cache
.mypy_cache
.ruff_cache
# IDE
.idea
.vscode
# Git
.git
.gitignore
# 文档(运行时不需要)
docs
*.md
LICENSE*
.dockerignore
docker-compose.yml
nginx.conf
+3 -32
View File
@@ -1,30 +1,12 @@
# ---- 应用 ----
# 运行环境: development | production
# production 启动时会强制校验:SECRET_KEY 非空且非弱值、鉴权已配置、DB 弱密码阻断。
APP_ENV=development
LOG_LEVEL=INFO
API_PORT=8000
FRONTEND_PORT=3000
# ---- 生产环境启动必填(缺少则拒绝启动) ----
# 1. SECRET_KEY: 加密主密钥与会话签名根密钥。
# 生成: openssl rand -base64 32
# 严禁使用 changeme/secret/123456 等弱值;变更后已加密配置无法解密。
SECRET_KEY=
# 2. 管理鉴权(至少一项):
# - ADMIN_PASSWORD: 后台登录初始密码(启动后自动 scrypt 哈希入库,之后在「系统配置」页修改)
# - ADMIN_API_KEY: 脚本直连接口用的密钥(请求头 X-API-Key)
# 两者都留空 = 不启用鉴权(仅本地开发)。
ADMIN_PASSWORD=
ADMIN_API_KEY=
# ---- 数据库 ----
POSTGRES_USER=football
POSTGRES_PASSWORD=football
POSTGRES_DB=football
POSTGRES_PORT=5433
# 本地开发用 localhost;Docker Compose 内会被 environment 覆盖为 postgres 服务名
POSTGRES_PORT=5432
DATABASE_URL=postgresql+asyncpg://football:football@localhost:5432/football
# ---- LLM (OpenAI-compatible,必填一个) ----
@@ -45,18 +27,7 @@ API_FOOTBALL_KEY=
# ---- CORS ----
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
# ---- 加密主密钥 ----
# 敏感配置(数据源/LLM API Key)入库加密与会话签名均由它派生。
# 只存部署机 .env,切勿入库或提交;生成: openssl rand -base64 32
# 变更后已加密配置无法解密,需在后台重新保存。
SECRET_KEY=
# ---- 管理接口鉴权 ----
# 管理后台登录密码(/admin 页面与采集/回测/回填等管理接口)。
# 此处为初始值:启动时自动迁移为 scrypt 哈希入库,迁移后本行可删除
# 之后请在后台「系统配置」页修改密码。留空 = 不启用密码登录(本地开发默认)。
ADMIN_PASSWORD=
# 管理接口会话有效期(小时),默认 7 天
ADMIN_SESSION_TTL_HOURS=168
# 备选: 机器/脚本直接调接口用的密钥(请求头 X-API-Key),与密码二选一即可
# 采集/回测/回填接口的访问密钥(请求头 X-API-Key)。
# 留空 = 不启用鉴权(本地开发默认);生产环境必须设置强随机值
ADMIN_API_KEY=
-47
View File
@@ -1,47 +0,0 @@
__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 默认排除规则(自定义规则请写在本行下方,不会被同步/恢复改动)-----
# 自定义规则写在下方(本行提示可删除)
+4 -18
View File
@@ -2,26 +2,12 @@ FROM python:3.11-slim
WORKDIR /app
# 直连官方源不稳定,固定使用清华 PyPI 镜像
ENV PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
# P2-5: 创建非 root 用户(容器安全最佳实践)
RUN groupadd --system profeto && useradd --system --gid profeto profeto
# 可复现构建:先安装锁定版本的依赖(含哈希校验),再安装本项目
COPY pyproject.toml requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
RUN pip install --no-cache-dir hatchling
COPY pyproject.toml README.md ./
COPY src ./src
RUN pip install --no-cache-dir .
# 将工作目录所有权移交给非 root 用户
RUN chown -R profeto:profeto /app
RUN pip install --no-cache-dir .
EXPOSE 8000
# 以非 root 用户运行
USER profeto
# 启动时先跑迁移,再启 uvicorn
CMD ["sh", "-c", "alembic upgrade head && uvicorn src.api.app:app --host 0.0.0.0 --port 8000"]
CMD ["uvicorn", "src.api.app:app", "--host", "0.0.0.0", "--port", "8000"]
+13 -40
View File
@@ -79,66 +79,39 @@ API Route → Application Service → Repository → UnitOfWork → DB
- Docker (运行 PostgreSQL)
- LLM API Key (OpenAI / Deepseek / Ollama 等)
### 方式一:Docker Compose 部署(推荐)
### 1. 安装
```bash
# 1. 克隆仓库
git clone https://git.bilidili.cn/shangfangjian/Profeto.git
cd Profeto
# 2. 配置环境变量
cp .env.example .env
# 编辑 .env,填入 LLM_API_KEY 和 BZZOIRO_KEY
# 3. 启动全部服务(自动构建 + 执行迁移)
docker compose up -d --build
# 4. 验证
curl http://localhost:8000/health
```
启动后访问:
- API 文档: http://localhost:8000/docs
- 前端界面: http://localhost:3000
> **说明**: `api` 容器启动时自动执行 `alembic upgrade head`,无需手动运行迁移。
### 方式二:本地开发部署
```bash
# 1. 克隆 + 安装
git clone https://git.bilidili.cn/shangfangjian/Profeto.git
cd Profeto
# 后端依赖
pip install -e ".[dev]"
# 2. 配置环境变量
# 配置环境变量
cp .env.example .env
# 编辑 .env,填入 LLM_API_KEY 和 BZZOIRO_KEY
```
# 3. 启动 PostgreSQL
### 2. 启动数据库
```bash
docker compose up -d postgres
alembic upgrade head # 首次运行需要执行迁移
```
# 4. 执行迁移
alembic upgrade head
### 3. 启动服务
# 5. 启动后端 (终端 1)
```bash
# 后端 (终端 1)
uvicorn src.api.app:app --reload
# 6. 启动前端 (终端 2)
# 前端 (终端 2)
cd frontend && npm install && npm run dev
```
后端运行在 `http://localhost:8000`,前端在 `http://localhost:5173`
## 安全与限流
- `/api/v1/predict`: 内存滑动窗口限流(10 次/分钟/IP),多 worker 时每进程独立计数
- 登录防爆破: 进程内内存计数,同上
- 公网部署建议 Nginx 层限流 + `TRUST_PROXY_HEADERS=True`
- 生产环境必须配置 `ADMIN_PASSWORD``ADMIN_API_KEY`(否则管理接口 503)
详见 [docs/06-deployment.md](docs/06-deployment.md#安全与限流)。
## API 概览
| 方法 | 路径 | 说明 |
-5
View File
@@ -70,11 +70,6 @@ def run_migrations_online() -> None:
with connectable.connect() as connection:
_ensure_version_table(connection)
# SQLAlchemy 2.0 autobegin:上面的探测 SELECT 会留下隐式事务。
# 若不结束,alembic(>=1.16)会判定处于「外部事务」而全程不提交,
# 迁移在连接关闭时被静默回滚(upgrade 退出码仍为 0)。
if connection.in_transaction():
connection.commit()
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
-30
View File
@@ -1,30 +0,0 @@
"""新增 app_settings 表(后台运行时配置)
Revision ID: 0010_app_settings
Revises: 0009_match_stats_xg_fields
Create Date: 2026-09-18
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '0010_app_settings'
down_revision: Union[str, None] = '0009_match_stats_xg_fields'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'app_settings',
sa.Column('key', sa.String(100), primary_key=True),
sa.Column('value', sa.Text(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
)
def downgrade() -> None:
op.drop_table('app_settings')
@@ -1,27 +0,0 @@
"""predictions 增加备选比分字段
Revision ID: 0011_prediction_alt_scores
Revises: 0010_app_settings
Create Date: 2026-09-19
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '0011_prediction_alt_scores'
down_revision: Union[str, None] = '0010_app_settings'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('predictions', sa.Column('alt_pred_home_goals', sa.Integer(), nullable=True))
op.add_column('predictions', sa.Column('alt_pred_away_goals', sa.Integer(), nullable=True))
def downgrade() -> None:
op.drop_column('predictions', 'alt_pred_away_goals')
op.drop_column('predictions', 'alt_pred_home_goals')
@@ -1,65 +0,0 @@
"""修复 injuries 唯一索引允许 NULL 重复 + 添加 return_date 字段
Revision ID: 0012_injuries_partial_unique_and_return_date
Revises: 0011_prediction_alt_scores
Create Date: 2026-09-20
Fix 4: 唯一索引 (player_id, fixture_id, injury_type) 三列均可 NULL,
PostgreSQL 允许多条 NULL 重复。改为 partial unique index:
WHERE player_id IS NOT NULL AND fixture_id IS NOT NULL
Fix 2: return_date 字段已在 ORM 声明,确保数据库列存在。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used Alembic.
revision: str = '0012_injuries_partial_unique_and_return_date'
down_revision: Union[str, None] = '0011_prediction_alt_scores'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
# 检查现有索引
indexes = {i["name"]: i for i in inspector.get_indexes("injuries")}
# Fix 4: 删除旧的全局唯一索引(允许 NULL 重复)
if "ix_injuries_player_fixture" in indexes:
op.drop_index("ix_injuries_player_fixture", table_name="injuries")
# 创建 partial unique index: 只在 player_id 和 fixture_id 都非空时强制唯一
op.execute(
"""
CREATE UNIQUE INDEX ix_injuries_player_fixture
ON injuries (player_id, fixture_id, injury_type)
WHERE player_id IS NOT NULL AND fixture_id IS NOT NULL
"""
)
# Fix 2: 确保 return_date 列存在(ORM 已声明,但早期迁移可能缺失)
columns = [c["name"] for c in inspector.get_columns("injuries")]
if "return_date" not in columns:
op.add_column(
"injuries",
sa.Column("return_date", sa.Date, nullable=True),
)
def downgrade() -> None:
# 删除 partial unique index
op.drop_index("ix_injuries_player_fixture", table_name="injuries")
# 恢复旧的全局唯一索引
op.create_index(
"ix_injuries_player_fixture",
"injuries",
["player_id", "fixture_id", "injury_type"],
unique=True,
)
@@ -1,74 +0,0 @@
"""修复预测唯一约束过粗,增加 mode + run_type 维度
Revision ID: 0013_predictions_unique_constraint_mode_run_type
Revises: 0012_injuries_partial_unique_and_return_date
Create Date: 2026-09-20
背景:
原唯一约束 (match_id, provider, model) 过粗,回测写入会覆盖未结算的实盘预测,
后续 settle 会污染评估数据。
修复:
1. 新增 run_type 列(默认 'live'),区分实盘与回测
2. 唯一约束改为 (match_id, provider, model, mode, run_type)
3. 已有数据 run_type 回填为 'live'
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '0013_predictions_unique_constraint_mode_run_type'
down_revision: Union[str, None] = '0012_injuries_partial_unique_and_return_date'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 1. 新增 run_type 列(先 nullable,回填后再改 NOT NULL)
op.add_column(
"predictions",
sa.Column("run_type", sa.String(10), nullable=True),
)
# 2. 回填已有数据:全部标记为 'live'
op.execute("UPDATE predictions SET run_type = 'live' WHERE run_type IS NULL")
# 3. 改为 NOT NULL
op.alter_column("predictions", "run_type", nullable=False)
# 4. 删除旧唯一约束
op.drop_constraint("uq_predictions_match_provider_model", "predictions", type_="unique")
# 5. 创建新唯一约束(包含 mode + run_type)
op.create_unique_constraint(
"uq_predictions_match_provider_model_mode_run_type",
"predictions",
["match_id", "provider", "model", "mode", "run_type"],
)
# 6. 添加 check constraint
op.create_check_constraint(
"ck_run_type_enum",
"predictions",
"run_type IN ('live', 'backtest')",
)
def downgrade() -> None:
# 1. 删除 check constraint
op.drop_constraint("ck_run_type_enum", "predictions", type_="check")
# 2. 删除新唯一约束
op.drop_constraint("uq_predictions_match_provider_model_mode_run_type", "predictions", type_="unique")
# 3. 恢复旧唯一约束
op.create_unique_constraint(
"uq_predictions_match_provider_model",
"predictions",
["match_id", "provider", "model"],
)
# 4. 删除 run_type 列
op.drop_column("predictions", "run_type")
@@ -1,39 +0,0 @@
"""为 predictions 表增加 agent_weights 列
Revision ID: 0014_predictions_agent_weights
Revises: 0013_predictions_unique_constraint_mode_run_type
Create Date: 2026-09-20
背景:
multi-agent 终裁的 agent_weights 原本只在 raw_response JSON 中,
无独立列,评估不便。本迁移增加 agent_weights JSONB 列,可空,旧行保持 NULL。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '0014_predictions_agent_weights'
down_revision: Union[str, None] = '0013_predictions_unique_constraint_mode_run_type'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 新增 agent_weights 列(JSONB, 可空, 旧行保持 NULL)
op.add_column(
"predictions",
sa.Column(
"agent_weights",
sa.dialects.postgresql.JSONB(),
nullable=True,
comment="multi-agent 终裁各专家权重, 格式: {expert_name: weight}",
),
)
def downgrade() -> None:
# 删除 agent_weights 列
op.drop_column("predictions", "agent_weights")
@@ -1,102 +0,0 @@
"""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')
-38
View File
@@ -1,38 +0,0 @@
"""新增 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')
-39
View File
@@ -1,39 +0,0 @@
"""扩展 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')",
)
-52
View File
@@ -1,52 +0,0 @@
"""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')
+3 -19
View File
@@ -17,24 +17,10 @@ services:
api:
build: .
# Fix 2: 容器内 DATABASE_URL 使用 postgres 服务名(非 localhost)
# Fix 3: 启动时先跑 alembic upgrade,再启 uvicorn
command: >
sh -c "alembic upgrade head &&
uvicorn src.api.app:app --host 0.0.0.0 --port 8000"
command: uvicorn src.api.app:app --host 0.0.0.0 --port 8000 --reload
ports:
- "${API_PORT:-8000}:8000"
environment:
# Fix 2: 容器内 DATABASE_URL 使用 postgres 服务名(非 localhost)
# 必须覆盖 .env 中的 DATABASE_URL,因为 Settings 不读 DB_HOST/DB_PORT
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:?POSTGRES_USER 未设置}:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD 未设置}@postgres:5432/${POSTGRES_DB:-football}
env_file: .env
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:8000/health/ready || exit 1"]
interval: 10s
timeout: 5s
retries: 3
start_period: 10s
depends_on:
postgres:
condition: service_healthy
@@ -44,13 +30,11 @@ services:
- ./alembic.ini:/app/alembic.ini
frontend:
# Fix 4: 多阶段构建 —— 先 build 静态文件,再复制到 nginx
build:
context: ./frontend
dockerfile: Dockerfile.frontend
image: nginx:alpine
ports:
- "${FRONTEND_PORT:-3000}:80"
volumes:
- ./frontend/dist:/usr/share/nginx/html
- ./frontend/nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- api
-13
View File
@@ -129,19 +129,6 @@ agents/
迭代 prompt 时:复制 `h2h_v1.md``h2h_v2.md`,改内容,传 `prompt_version: "v2"`
`predictions.prompt_version` 存的是 `multi_v2`,与 single 模式的 `v1`/`v2` 天然分组,可在 eval summary 中 A/B 对比。
### 版本纪律(强制)
> **改 prompt 内容必须 bump 版本号(v2→v3),禁止默默修改 `*_v1.md` 内容却不改版本。**
原因:
1. **可复现性**:`predictions.prompt_version` 决定哪份 prompt 产生了历史预测;篡改 v1 会让历史预测的 prompt 来源失真,eval 对比失效。
2. **A/B 可信度**:`get_eval_summary``(provider, model, prompt_version)` 分组。若 v1 内容在不同时间指向不同 prompt,则 v1 桶内数据不可比。
3. **缓存一致性**:模板内容 hash 写入缓存键(`_prompt_template_hash`),版本不变则 hash 不变,命中旧缓存。bump 版本自动让旧缓存失效。
**流程**:改 prompt → 新建 `*_v{N+1}.md` → 新请求传 `prompt_version=v{N+1}` → 旧版本文件保持不变(供历史复现)。
代码保证:写入 DB 的 `prompt_version``_load_prompt_template(version)` / `load_agent_prompt(name, version)` 加载的文件**严格一致**,不会漂移。
## 如何新增一个专家 Agent
三步:
-11
View File
@@ -17,17 +17,6 @@
BZZOIRO_LEAGUE_IDS = {"E0": 1, "SP1": 3, "D1": 5, "I1": 4, "F1": 6, "CL": 7, "EL": 8}
```
> ⚠️ **统计字段映射待验证**:当前字段名基于常见足球 API 模式推测(如 `home_shots`/`away_shots`),
> 未经真实 bzzoiro 响应校验。若真实字段不同,映射结果将为 None。
> 请提供一份 event 样例核对以下字段:
> - 射门: `home_shots` / `away_shots`
> - 射正: `home_shots_on_target` / `away_shots_on_target`
> - 角球: `home_corners` / `away_corners`
> - 控球: `home_possession`
> - xG: `home_xg` / `away_xg`
> - 黄牌: `home_yellow_cards` / `away_yellow_cards`
> - 红牌: `home_red_cards` / `away_red_cards`
### understat
- 端点:`/getLeagueData/{league}/{season}`,返回 JS 包裹的 JSON(需正则提取)
+10 -136
View File
@@ -13,26 +13,22 @@
cp .env.example .env
# 编辑 .env: 填 LLM_API_KEY / BZZOIRO_KEY
# 2. 启动(自动执行数据库迁移)
# 2. 启动(自动建表)
docker compose up -d --build
# 3. 验证
curl http://localhost:8000/health
```
`docker-compose.yml` 包含 3 个服务:
`docker-compose.yml` 仅 2 个服务:
| 服务 | 端口 | 说明 |
|---|---|---|
| `postgres` | 5433 | PostgreSQL 16 |
| `api` | 8000 | FastAPI 应用(启动时自动执行 `alembic upgrade head`) |
| `frontend` | 3000 | React 前端(多阶段构建,nginx 服务静态文件) |
| `postgres` | 5432 | PostgreSQL 16 |
| `api` | 8000 | FastAPI 应用 |
数据卷 `pgdata` 持久化数据库,重启不丢数据。
> **注意**: `api` 服务启动时会先执行 `alembic upgrade head` 迁移数据库,再启动 uvicorn。
> 容器内数据库连接自动使用 `postgres` 服务名(通过 compose `environment` 覆盖 `.env` 中的 `DB_HOST`)。
## 本地开发部署
```bash
@@ -67,13 +63,7 @@ cd frontend && npm install && npm run dev
|---|---|---|---|
| `APP_ENV` | ❌ | `development` | `production` / `development` |
| `LOG_LEVEL` | ❌ | `INFO` | 日志级别 |
| `API_PORT` | | `8000` | API 服务端口映射 |
| `FRONTEND_PORT` | ❌ | `3000` | 前端服务端口映射 |
| `POSTGRES_USER` | ✅ | — | PostgreSQL 用户名 |
| `POSTGRES_PASSWORD` | ✅ | — | PostgreSQL 密码 |
| `POSTGRES_DB` | ❌ | `football` | PostgreSQL 数据库名 |
| `POSTGRES_PORT` | ❌ | `5433` | PostgreSQL 端口映射 |
| `DATABASE_URL` | ✅ | — | PostgreSQL 连接 URL(Docker 内会被覆盖) |
| `DATABASE_URL` | | — | PostgreSQL 连接 URL |
| `LLM_PROVIDER` | ❌ | `openai` | 提供商名(仅标记) |
| `LLM_API_KEY` | ✅ | — | API Key |
| `LLM_BASE_URL` | ❌ | `https://api.openai.com/v1` | 接口地址(Ollama/Deepseek 用) |
@@ -82,11 +72,9 @@ cd frontend && npm install && npm run dev
| `LLM_SPECIALIST_MODEL` | ❌ | — | 专家模型(回落 `LLM_MODEL`) |
| `LLM_AGGREGATOR_MODEL` | ❌ | — | 终裁模型(回落 `LLM_MODEL`) |
| `BZZOIRO_KEY` | ✅ | — | bzzoiro 数据源 Key |
| `BZZOIRO_BASE` | ❌ | `https://sports.bzzoiro.com/api/v2` | bzzoiro 接口地址 |
| `API_FOOTBALL_KEY` | ❌ | — | 伤停数据源 Key |
| `CORS_ORIGINS` | ❌ | `http://localhost:5173,...` | 允许的跨域来源 |
| `SECRET_KEY` | ❌ | — | 加密主密钥(生产环境必填) |
| `ADMIN_PASSWORD` | ❌ | — | 管理后台密码(留空=不启用) |
| `ADMIN_API_KEY` | ❌ | — | 机器/脚本调用的 API Key |
## LLM 提供商配置示例
@@ -139,16 +127,9 @@ alembic revision --autogenerate -m "描述"
alembic revision -m "描述"
```
**Docker Compose 自动迁移**: `api` 容器启动时会自动执行 `alembic upgrade head`,
无需手动运行。本地开发时需手动执行迁移。
已有迁移:
- `0001_initial`: 初始 5 张表
- `0001_initial`: 初始 5 张表,0003 增加 injuries,0004 增加约束,0005 增加时间语义
- `0002_agent_outputs`: predictions 加 `mode` + `agent_outputs`
- `0003_injuries`: 增加 injuries 表
- `0004_snapshot_and_constraints`: 增加约束
- `0005_prediction_status_and_stats_provenance`: 增加时间语义
- `0006-0012`: 后续 schema 调整、约束命名对齐、partial unique index 等
## 备份与恢复
@@ -162,113 +143,6 @@ cat backup.sql | docker exec -i profeto-postgres psql -U football football
## 监控
### 健康检查
| 端点 | 含义 | HTTP 状态码 |
|---|---|---|
| `/health` | 存活检查(liveness) | 始终 200(进程在跑即活) |
| `/health/ready` | 就绪检查(readiness) | DB 可达 200,不可达 **503** |
### 探针配置
#### Docker Compose
`docker-compose.yml` 已为 `api` 服务配置 readiness:
```yaml
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:8000/health/ready || exit 1"]
interval: 10s
timeout: 5s
retries: 3
start_period: 10s
```
**要点**:必须指向 `/health/ready` 而非 `/health`——后者始终 200,在数据库故障时仍会接收流量,导致请求全部失败。
#### Kubernetes
```yaml
livenessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 5
periodSeconds: 15
readinessProbe:
httpGet:
path: /health/ready
port: 8000
initialDelaySeconds: 10
periodSeconds: 10
failureThreshold: 3
```
**两探针必须区分**:
- `livenessProbe``/health`:仅在进程死锁/崩溃时重启,避免误杀。
- `readinessProbe``/health/ready`:DB 不可用时停止转发流量,恢复后自动切回。
#### 验证
```bash
# 宿主机直接运行(经本地 8000 端口)
python3 tests/test_health_ready.py
```
### 评估
## 安全与限流
### 内存限流(按进程)
`/api/v1/predict` 与登录防爆破均使用**进程内内存**计数:
| 机制 | 位置 | 局限 |
|------|------|------|
| `/predict` 限流 | `_RateLimiter`(内存) | 每 worker 独立计数,不共享 |
| 登录防爆破 | `_fail_times`(内存) | 同上 |
**多 worker 部署时**(如 `uvicorn --workers 4`),每进程各自计数,实际限额为 `N × 单进程限制`
### 公网部署建议
```
┌─────────┐ ┌──────────┐ ┌──────────┐
│ Client │────▶│ Nginx │────▶│ API │
│ │ │ 限流层 │ │ 内存限流 │
└─────────┘ └──────────┘ └──────────┘
```
**推荐配置**:
1. **Nginx 层限流**(第一道防线):
```nginx
limit_req_zone $binary_remote_addr zone=predict:10m rate=10r/m;
location /api/v1/predict {
limit_req zone=predict burst=20 nodelay;
proxy_pass http://api:8000;
}
```
2. **TRUST_PROXY_HEADERS=True** 时必须由可信反代设置 `X-Forwarded-For`:
```nginx
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
```
- 若为 `False`: 仅用 `request.client.host`,忽略 `X-Forwarded-For`,防伪造
- 若为 `True`: 解析 `X-Forwarded-For` 第一个 IP,反代后方可信
3. **生产环境必须配置管理鉴权**:
```bash
APP_ENV=production
REQUIRE_ADMIN_AUTH=True
ADMIN_PASSWORD=your_secure_password
```
未配置时 `/admin` 等管理接口返回 503。
### 参数调优
| 参数 | 默认 | 说明 |
|------|------|------|
| `_predict_limiter.max_requests` | 10 | 每分钟每 IP 最大请求数 |
| `_predict_limiter.window_seconds` | 60 | 滑动窗口时长 |
| `ADMIN_SESSION_TTL_HOURS` | 168 | 管理会话有效期(天) |
- `/health`: 存活检查
- 日志:容器 stdout(`docker compose logs -f api`)
- 评估汇总:`GET /api/v1/eval/summary`(准确率/RMSAE/校准度)
+7 -45
View File
@@ -2,70 +2,32 @@
## 本地开发环境搭建
### 锁定依赖策略
项目使用 [pip-tools](https://github.com/jazzband/pip-tools) 锁定依赖版本,确保本地、CI、Docker 三端一致:
| 文件 | 用途 | 生成命令 |
|---|---|---|
| `requirements.txt` | 生产依赖锁定(含 SHA256 哈希) | `pip-compile pyproject.toml --generate-hashes` |
| `requirements-dev.txt` | 开发+CI 依赖锁定(含哈希) | `pip-compile pyproject.toml --extra dev --generate-hashes` |
### 安装步骤
```bash
# 1. 克隆并进入项目
cd Profeto
# 2. 创建虚拟环境
python3.11 -m venv .venv
source .venv/bin/activate
# 2. 安装依赖(含 dev)
pip install -e ".[dev]"
# 3. 安装 pip-tools(用于同步锁定依赖)
pip install pip-tools
# 4. 同步生产+开发依赖到当前环境(严格按 lock 文件版本,含哈希校验)
pip-sync requirements-dev.txt
# 5. 启动 PostgreSQL(或在 .env 配置外部库)
# 3. 启动 PostgreSQL
docker run -d --name profeto-pg \
-e POSTGRES_USER=football -e POSTGRES_PASSWORD=football -e POSTGRES_DB=football \
-p 5432:5432 postgres:16-alpine
# 6. 配置环境变量
# 4. 配置环境变量
cp .env.example .env
# 编辑 .env 填 LLM_API_KEY / BZZOIRO_KEY
# 7. 建表
# 5. 建表
alembic upgrade head
# 8. 启动 API(热重载)
# 6. 启动 API(热重载)
uvicorn src.api.app:app --reload
# 9. 启动前端(另一个终端)
# 7. 启动前端(另一个终端)
cd frontend && npm install && npm run dev
```
> **注意**:不要用 `pip install -e ".[dev]"` 直接安装——它按 pyproject 下界约束解析,版本可能与 lock 文件不一致。统一用 `pip-sync requirements-dev.txt` 保证三端一致。
### 变更依赖时
```bash
# 1. 编辑 pyproject.toml(调整依赖或版本约束)
# 2. 重新生成 lock 文件(含哈希)
pip-compile pyproject.toml --generate-hashes --output-file=requirements.txt --index-url=https://pypi.tuna.tsinghua.edu.cn/simple
pip-compile pyproject.toml --extra dev --generate-hashes --output-file=requirements-dev.txt --index-url=https://pypi.tuna.tsinghua.edu.cn/simple
# 3. 同步到本地环境
pip-sync requirements-dev.txt
# 4. 提交 lock 文件
git add requirements.txt requirements-dev.txt pyproject.toml
```
> **禁止无故大升级主版本依赖**:仅升级真正需要的包,并重新跑全量测试。
## 项目结构
```
-5
View File
@@ -1,5 +0,0 @@
# 前端构建时不需要的文件
node_modules
dist
.git
*.log
-25
View File
@@ -1,25 +0,0 @@
# Fix 4: 前端多阶段构建 —— 构建静态文件 + nginx 服务
FROM node:20-alpine AS builder
WORKDIR /app
# 安装依赖
COPY package.json package-lock.json ./
RUN npm ci
# 构建
COPY . .
RUN npm run build
# 生产阶段: nginx 服务静态文件
FROM nginx:alpine
# 从 builder 阶段复制构建产物
COPY --from=builder /app/dist /usr/share/nginx/html
# nginx 配置(在 compose 中通过 volume 挂载,此处仅作备用)
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
-4
View File
@@ -10,10 +10,6 @@ server {
location /api/ {
proxy_pass http://api:8000/api/;
proxy_http_version 1.1;
# LLM 多专家预测/回测耗时长(可达数分钟),默认 60s 会掐断请求返回 504
proxy_connect_timeout 10s;
proxy_send_timeout 60s;
proxy_read_timeout 300s;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
+10
View File
@@ -8,6 +8,7 @@
"name": "profeto-frontend",
"version": "0.1.0",
"dependencies": {
"@fontsource/noto-serif-sc": "^5.3.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.6"
@@ -709,6 +710,15 @@
"node": ">=12"
}
},
"node_modules/@fontsource/noto-serif-sc": {
"version": "5.3.0",
"resolved": "https://registry.npmmirror.com/@fontsource/noto-serif-sc/-/noto-serif-sc-5.3.0.tgz",
"integrity": "sha512-0/zaEFkidiWldE62rTeD74x8ygUsQvejiSNtO0LQxQk3qpaHnlMZ3w4C7yH80B4KTIg8VKeFP4oSgwWMchY9+g==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+1
View File
@@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
"@fontsource/noto-serif-sc": "^5.3.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.6"
+34 -64
View File
@@ -1,20 +1,14 @@
/**
* 主应用入口
*
* 页面结构:
* - / → 先知主站(报纸风赛程 + 预测),报头含「评估」「管理」入口
* - /admin/* → 管理后台(鉴权门禁,未登录引导登录)
*
* 导航策略:
* - 首页报头放「评估」「管理」入口(极简,不另加导航条)
* - 管理后台由 AdminLayout 侧边栏处理所有管理页导航
* - 未登录访问管理 → AdminLayout 门禁 → 登录页(不静默失败)
* 整合前台(报纸风格)和后台(暗色管理)的路由。
* - / → 先知(Profeto)主站
* - /admin/* → 管理后台
*/
import { BrowserRouter, Routes, Route, Navigate, Link } from 'react-router-dom'
import { ErrorBoundary } from './components/ErrorBoundary'
import Matches from './pages/Matches'
import Standings from './pages/Standings'
import { adminRoutes } from './admin/routes'
/** 报眉日期行 */
@@ -27,70 +21,47 @@ function dateLine(): string {
})
}
function StandingsLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-paper-50">
<header className="masthead-rule">
<div className="mx-auto max-w-5xl px-5 sm:px-8">
<div className="border-b border-ink-900 py-5 text-center sm:py-6">
<h1 className="font-brush text-5xl text-ink-900 sm:text-6xl">
</h1>
</div>
<div className="flex items-center justify-between border-b border-ink-200 py-2 text-2xs text-ink-500">
<span>{dateLine()}</span>
<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 issueNo(): number {
const now = new Date()
const start = new Date(now.getFullYear(), 0, 0)
return Math.floor((now.getTime() - start.getTime()) / 86400000)
}
function HomePage() {
return (
<div className="min-h-screen bg-paper-50">
{/* ── 报头:粗线 + 居中刊名 + 日期与分区链接 ── */}
<header className="masthead-rule">
// grain: 纸张噪点氛围层;报头、赛程行、预测版各自带 rise-in 入场
<div className="grain min-h-screen bg-paper-50">
{/* ── 报头:Oxford 双线 + 大字刊名 + 期号报眉 ── */}
<header className="masthead-rule rise-in">
<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">
<h1 className="font-brush text-5xl text-ink-900 sm:text-6xl">
<div className="border-b border-ink-900 py-6 text-center sm:py-8">
<h1 className="font-serif text-5xl font-bold leading-none tracking-[0.16em] text-ink-900 sm:text-6xl">
<span className="wordmark ml-3 align-baseline text-base font-normal italic tracking-normal text-ink-500 sm:text-lg">
Profeto
</span>
</h1>
<p className="mt-3 text-2xs tracking-[0.4em] text-ink-500">
· ·
</p>
</div>
<div className="flex items-center justify-between border-b border-ink-200 py-2 text-2xs text-ink-500">
<span>{dateLine()}</span>
<nav className="flex items-center gap-4" aria-label="页面导航">
<Link to="/standings" className="text-ink-500 hover:text-press transition-colors">
<span className="tabular-nums">
<span className="text-ink-700"> {issueNo()} </span>
<span className="mx-2 text-ink-300" aria-hidden="true">|</span>
{dateLine()}
</span>
<span className="flex items-center gap-3">
<span className="tracking-[0.3em] text-ink-400"></span>
<Link
to="/admin"
className="flex min-h-[32px] items-center gap-1.5 text-press transition-colors hover:text-press-dark"
>
<span aria-hidden="true"></span>
</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>
</span>
</div>
</div>
</header>
@@ -114,7 +85,6 @@ export default function App() {
<BrowserRouter>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/standings" element={<StandingsLayout><Standings /></StandingsLayout>} />
{adminRoutes.map(route => (
<Route key={route.path} path={route.path} element={route.element}>
{route.children.map(child => (
+98 -283
View File
@@ -6,139 +6,18 @@
*/
import { useState, useEffect, useCallback } from 'react'
import { NavLink, Outlet, useLocation } from 'react-router-dom'
import { fetchAuthState, logout, UNAUTHORIZED_EVENT } from './api'
import { NavLink, Link, Outlet, useLocation } from 'react-router-dom'
import { fetchHealth } from './dal'
import Login from './Login'
import { useCommandPalette, CommandPalette } from './useCommandPalette'
// 线条风格 SVG 图标组件
function Icon({ name }: { name: string }) {
const common = 'nav-icon'
switch (name) {
case 'collection':
return (
<svg className={common} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
<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" />
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
<line x1="12" y1="22.08" x2="12" y2="12" />
</svg>
)
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' },
],
},
const NAV_ITEMS = [
{ to: '/admin', label: '仪表盘', icon: '◇', end: true },
{ to: '/admin/collection', label: '数据采集', icon: '◈' },
{ to: '/admin/predictions', label: '预测管理', icon: '◆' },
{ to: '/admin/backtest', label: '回测管理', icon: '◉' },
{ to: '/admin/monitoring', label: '监控面板', icon: '◐' },
{ to: '/admin/data-sources', label: '数据源', icon: '◫' },
{ to: '/admin/llm-config', label: 'LLM 配置', icon: '◬' },
{ to: '/admin/config', label: '系统配置', icon: '◑' },
]
/** 报眉日期行,与前台同款式 */
@@ -154,32 +33,7 @@ function dateLine(): string {
export default function AdminLayout() {
const [sidebarOpen, setSidebarOpen] = useState(false)
const [healthOk, setHealthOk] = useState<boolean | null>(null)
const [authed, setAuthed] = useState<boolean | null>(null)
const location = useLocation()
const palette = useCommandPalette(NAV_PAGES)
// 登录门禁:挂载时探测会话,收到 401 事件(会话过期)自动切回登录页
useEffect(() => {
let alive = true
fetchAuthState()
.then(s => alive && setAuthed(s.authenticated))
.catch(() => alive && setAuthed(false))
const onUnauthorized = () => setAuthed(false)
window.addEventListener(UNAUTHORIZED_EVENT, onUnauthorized)
return () => {
alive = false
window.removeEventListener(UNAUTHORIZED_EVENT, onUnauthorized)
}
}, [])
const handleLogout = useCallback(async () => {
try {
await logout()
} catch {
/* 会话可能已失效,直接切回登录页 */
}
setAuthed(false)
}, [])
const checkHealth = useCallback(async () => {
try {
@@ -211,68 +65,8 @@ export default function AdminLayout() {
return () => document.removeEventListener('keydown', handler)
}, [])
// 登录门禁:未登录只渲染登录页,不泄露后台任何内容
if (authed === null) {
return (
<div className="flex h-screen items-center justify-center bg-paper-50 text-xs text-ink-400">
</div>
)
}
if (!authed) {
return <Login onSuccess={() => setAuthed(true)} />
}
return (
<div className="flex h-screen 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">
<div className="flex h-screen overflow-hidden bg-paper-50 text-ink-800">
{/* ── 移动端遮罩层 ── */}
{sidebarOpen && (
<div
@@ -292,86 +86,107 @@ export default function AdminLayout() {
`}
aria-label="主导航"
>
{/* 侧栏顶部 */}
<div className="flex items-center border-b border-ink-900 px-5 py-4">
<h1 className="font-brush text-xl text-ink-900"></h1>
{/* 报头 */}
<div className="flex items-center justify-between border-b border-ink-900 px-5 py-4">
<h1 className="font-serif text-lg font-bold tracking-widest text-ink-900">
<span className="wordmark ml-2 align-baseline 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>
{/* 导航:分组 + 小节标题 */}
<nav className="flex-1 overflow-y-auto px-3 py-3" aria-label="管理导航">
{/* 仪表盘独立(始终第一项) */}
<NavLink
to="/admin"
end
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="chart" />
</NavLink>
{NAV_SECTIONS.map(section => (
<div key={section.title} className="mt-4">
<p className="mb-1 px-3 text-2xs font-medium uppercase tracking-widest text-ink-400">
{section.title}
</p>
<ul className="space-y-0.5">
{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} />
{/* 导航:选中项以墨色方块标记(红色只留给异常状态,与前台纪律一致) */}
<nav className="flex-1 overflow-y-auto px-3 py-4" aria-label="管理导航">
<ul className="space-y-0.5">
{NAV_ITEMS.map(item => (
<li key={item.to}>
<NavLink
to={item.to}
end={item.end}
className={({ isActive }) =>
`flex min-h-[44px] items-center gap-2.5 px-3 py-2.5 text-sm transition-colors ${
isActive
? 'bg-paper-200/70 font-medium text-ink-900'
: 'text-ink-500 hover:bg-paper-100 hover:text-ink-900'
}`
}
>
{({ isActive }) => (
<>
<span
className={`inline-block h-1.5 w-1.5 flex-shrink-0 ${isActive ? 'bg-ink-900' : 'bg-transparent'}`}
aria-hidden="true"
/>
<span className="text-base leading-none opacity-50" aria-hidden="true">
{item.icon}
</span>
{item.label}
</NavLink>
</li>
))}
</ul>
</div>
))}
</>
)}
</NavLink>
</li>
))}
</ul>
</nav>
{/* 底部 */}
<div className="border-t border-ink-200 px-4 py-3 space-y-2">
<a
href="/"
className="flex min-h-[36px] items-center gap-2 text-xs text-ink-500 transition-colors hover:text-press"
<div className="border-t border-ink-200 px-4 py-3">
<Link
to="/"
className="flex min-h-[44px] items-center gap-2 text-xs text-ink-500 transition-colors hover:text-press"
>
<span aria-hidden="true"></span>
</a>
<p className="text-2xs text-ink-300">v1.0</p>
</Link>
</div>
</aside>
{/* ── 主内容区 ── */}
<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">
<Outlet />
</div>
</main>
</div>
<div className="flex flex-1 flex-col overflow-hidden">
{/* 报眉:日期 + 系统状态 */}
<header className="flex h-11 flex-shrink-0 items-center justify-between border-b border-ink-200 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>
<span className="hidden text-2xs text-ink-500 sm:inline">{dateLine()}</span>
</div>
{/* 命令面板(⌘K) */}
<CommandPalette
open={palette.open}
query={palette.query}
setQuery={palette.setQuery}
items={palette.items}
onClose={() => palette.setOpen(false)}
/>
<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>
<Link
to="/"
className="text-press transition-colors hover:text-press-dark sm:hidden"
aria-label="返回前台"
>
</Link>
</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>
)
}
-201
View File
@@ -1,201 +0,0 @@
/**
* Admin 后台 - 专家与终裁独立 LLM 配置卡片
*
* 每个角色(5 专家 + 终裁)可独立覆盖 模型 / 接口地址 / API Key;
* 留空字段不改动,「恢复继承」删除该角色全部覆盖。
*/
import { useCallback, useEffect, useState } from 'react'
import { fetchLLMAgents, updateSetting, clearSetting } from './dal'
import type { LLMAgentConfig } from './types'
import { Card, CardBody, CardHeader, Badge, Alert, Spinner, SkeletonBlock } from './components'
type FieldKey = 'model' | 'base_url' | 'api_key'
const FIELD_META: { key: FieldKey; label: string; sensitive: boolean; hint: string }[] = [
{ key: 'model', label: '模型', sensitive: false, hint: '留空保持现状;未覆盖时继承默认' },
{ key: 'base_url', label: '接口地址', sensitive: false, hint: '留空保持现状;未覆盖时继承全局' },
{ key: 'api_key', label: 'API Key', sensitive: true, hint: '留空保持现状;未覆盖时继承全局' },
]
const KEY_BY_FIELD: Record<FieldKey, (id: string) => string> = {
model: id => `AGENT_${id.toUpperCase()}_MODEL`,
base_url: id => `AGENT_${id.toUpperCase()}_BASE_URL`,
api_key: id => `AGENT_${id.toUpperCase()}_API_KEY`,
}
export default function AgentLLMCard() {
const [agents, setAgents] = useState<LLMAgentConfig[]>([])
const [loading, setLoading] = useState(true)
const [expandedId, setExpandedId] = useState<string | null>(null)
const [form, setForm] = useState<Record<FieldKey, string>>({ model: '', base_url: '', api_key: '' })
const [busy, setBusy] = useState(false)
const [notice, setNotice] = useState<{ ok: boolean; text: string } | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
setAgents(await fetchLLMAgents())
} catch {
setAgents([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
load()
}, [load])
function toggleExpand(agent: LLMAgentConfig) {
if (expandedId === agent.id) {
setExpandedId(null)
return
}
setExpandedId(agent.id)
setNotice(null)
// 预填非敏感覆盖值;API Key 不回填
setForm({
model: agent.fields.model.origin === 'db' ? agent.fields.model.masked : '',
base_url: agent.fields.base_url.origin === 'db' ? agent.fields.base_url.masked : '',
api_key: '',
})
}
async function handleSave(agent: LLMAgentConfig) {
setBusy(true)
setNotice(null)
try {
const nonEmpty = (FIELD_META.filter(f => form[f.key].trim())).map(f => f)
if (nonEmpty.length === 0) {
setNotice({ ok: false, text: '没有需要保存的修改(全部为空)' })
return
}
for (const f of nonEmpty) {
await updateSetting(KEY_BY_FIELD[f.key](agent.id), form[f.key].trim())
}
setNotice({ ok: true, text: `${agent.label} 配置已保存,立即生效` })
setExpandedId(null)
await load()
} catch (err) {
setNotice({ ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '保存失败' })
} finally {
setBusy(false)
}
}
async function handleReset(agent: LLMAgentConfig) {
setBusy(true)
setNotice(null)
try {
for (const f of FIELD_META) {
await clearSetting(KEY_BY_FIELD[f.key](agent.id))
}
setNotice({ ok: true, text: `${agent.label} 已恢复继承默认` })
setExpandedId(null)
await load()
} catch (err) {
setNotice({ ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '恢复失败' })
} finally {
setBusy(false)
}
}
const hasOverride = (agent: LLMAgentConfig) =>
Object.values(agent.fields).some(f => f.origin === 'db')
return (
<Card>
<CardHeader
title="专家与终裁 LLM 配置"
description="可为每个角色单独指定模型、接口地址或 API Key;未覆盖的角色按「专家层/终裁层默认 → 全局」继承"
action={
<button onClick={load} disabled={loading} className="btn btn-sm">
{loading ? (<><Spinner /> </>) : '刷新'}
</button>
}
/>
<CardBody className="px-0 sm:px-0">
{loading ? (
<div className="space-y-2 px-4 sm:px-5">
{[1, 2, 3, 4, 5, 6].map(i => <SkeletonBlock key={i} className="h-9 w-full" />)}
</div>
) : agents.length === 0 ? (
<p className="py-6 text-center text-xs text-ink-400"></p>
) : (
<>
{notice && (
<div className="px-4 pb-2 sm:px-5">
<Alert kind={notice.ok ? 'ok' : 'error'} title={notice.text} />
</div>
)}
{agents.map(agent => {
const expanded = expandedId === agent.id
return (
<div key={agent.id} className="border-b border-ink-200 last:border-b-0">
{/* 行:角色名 + 生效模型 + 配置按钮 */}
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 px-4 py-2.5 sm:px-5">
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<span className="font-serif text-sm font-bold text-ink-900">{agent.label}</span>
{hasOverride(agent) && <Badge status="success"></Badge>}
</div>
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-mono text-2xs text-ink-500">{agent.effective_model}</span>
<button onClick={() => toggleExpand(agent)} disabled={busy} className="btn btn-sm flex-shrink-0">
{expanded ? '收起' : '配置'}
</button>
</div>
</div>
{/* 展开的编辑表单 */}
{expanded && (
<div className="space-y-3 border-t border-ink-200 bg-paper-100/40 px-4 py-3 sm:px-5">
{FIELD_META.map(f => {
const state = agent.fields[f.key]
return (
<div key={f.key} className="grid gap-1 sm:grid-cols-[96px_minmax(0,1fr)] sm:items-center sm:gap-3">
<label className="text-xs text-ink-500">{f.label}</label>
<div>
<input
type={f.sensitive ? 'password' : 'text'}
value={form[f.key]}
onChange={e => setForm(prev => ({ ...prev, [f.key]: e.target.value }))}
placeholder={
f.key === 'api_key' && state.origin === 'db'
? `已覆盖(${state.masked}),留空保持不变`
: f.hint
}
autoComplete="off"
className="field w-full"
/>
</div>
</div>
)
})}
<div className="flex items-center justify-between gap-2 pt-1">
<p className="text-2xs text-ink-400">
:{agent.effective_model}
</p>
<div className="flex gap-2">
{hasOverride(agent) && (
<button onClick={() => handleReset(agent)} disabled={busy} className="btn btn-sm">
</button>
)}
<button onClick={() => handleSave(agent)} disabled={busy} className="btn btn-solid btn-sm">
{busy ? (<><Spinner /> </>) : '保存'}
</button>
</div>
</div>
</div>
)}
</div>
)
})}
</>
)}
</CardBody>
</Card>
)
}
-96
View File
@@ -1,96 +0,0 @@
/**
* Admin 后台 - 登录页(报刊风)
*
* 密码验证通过后由服务端写入 HttpOnly 会话 Cookie。
*/
import { useState } from 'react'
import { ApiError, login } from './api'
export default function Login({ onSuccess }: { onSuccess: () => void }) {
const [password, setPassword] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState('')
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!password || submitting) return
setSubmitting(true)
setError('')
try {
await login(password)
onSuccess()
} catch (err) {
setError(
err instanceof ApiError
? err.message.split('\n')[0]
: '登录失败,请检查网络连接',
)
} finally {
setSubmitting(false)
}
}
return (
<div className="flex min-h-screen flex-col bg-paper-50 text-ink-800">
<header className="masthead-rule">
<div className="mx-auto w-full max-w-md px-5 pt-12 sm:pt-16">
<div className="border-b border-ink-900 py-5 text-center">
<h1 className="font-serif text-3xl font-bold tracking-widest text-ink-900">
<span className="ml-3 align-baseline font-serif text-sm font-normal italic tracking-normal text-ink-500">
Profeto
</span>
</h1>
<p className="mt-2 text-2xs tracking-[0.4em] text-ink-500"> · </p>
</div>
</div>
</header>
<main className="flex flex-1 items-start justify-center px-5 py-10">
<form
onSubmit={handleSubmit}
className="w-full max-w-sm border border-ink-300 bg-white p-6 shadow-[4px_4px_0_0_rgba(0,0,0,0.06)]"
>
<label htmlFor="admin-password" className="block text-xs font-medium tracking-wide text-ink-700">
</label>
<input
id="admin-password"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
placeholder="输入服务器 .env 中的 ADMIN_PASSWORD"
autoFocus
autoComplete="current-password"
className="field mt-2 w-full"
/>
{error && (
<p className="mt-3 border-l-2 border-press bg-press-wash/40 px-3 py-2 text-2xs leading-relaxed text-press-dark">
{error}
</p>
)}
<button
type="submit"
disabled={!password || submitting}
className="btn btn-solid mt-5 w-full justify-center"
>
{submitting ? '验证中…' : '登 录'}
</button>
<p className="mt-4 border-t border-ink-200 pt-3 text-center text-2xs leading-relaxed text-ink-400">
.env,; 5 10
</p>
</form>
</main>
<footer className="pb-8 text-center text-2xs text-ink-400">
<a href="/" className="transition-colors hover:text-press">
</a>
</footer>
</div>
)
}
+3 -3
View File
@@ -26,7 +26,7 @@ src/admin/
├── Monitoring.tsx # 监控面板(存活 + 数据库就绪,30s 自动巡检)
├── DataSources.tsx # 数据源管理(数据源配置与测试)
├── LLMConfig.tsx # LLM 配置(模型连接与统计)
└── Config.tsx # 系统配置(登录鉴权说明 + .env 查看与修改指南)
└── Config.tsx # 系统配置(管理员密钥 + .env 查看与修改指南)
```
## 页面说明
@@ -69,8 +69,8 @@ src/admin/
- 可用模型列表
### 8. 系统配置 (`/admin/config`)
- **登录与鉴权**: 后台由密码登录保护(服务器 .env 的 ADMIN_PASSWORD),
会话以 HttpOnly Cookie 保存;脚本直连接口可使用 ADMIN_API_KEY(X-API-Key 请求头)
- **管理员密钥管理**: 保存 X-API-Key 到本机 localStorage,之后所有请求自动附带;
后端配置了 ADMIN_API_KEY 时,采集 / 回测 / 结算接口依赖此密钥
- 配置列表: 脱敏显示 .env 配置项
- 配置修改指南: SSH 修改 .env + 重启服务
-160
View File
@@ -1,160 +0,0 @@
/**
* Admin 后台 - 配置项行组件(报刊风)
*
* 展示态:键名 + 来源徽标(数据库覆盖 / .env 默认 / 未配置) + 脱敏值 + 操作按钮
* 编辑态:输入框 + 保存/取消
* 由数据源页与 LLM 配置页共用。
*/
import { useEffect, useState } from 'react'
import type { DataSourceSetting } from './types'
import { Badge, Spinner } from './components'
export const ORIGIN_BADGE: Record<DataSourceSetting['origin'], { text: string; status: 'success' | 'info' | 'error' }> = {
db: { text: '数据库覆盖', status: 'success' },
env: { text: '.env 默认', status: 'info' },
none: { text: '未配置', status: 'error' },
}
export default function SettingRow({
setting,
editing,
busy,
onEdit,
onCancel,
onSave,
onClear,
detectModels,
}: {
setting: DataSourceSetting
editing: boolean
busy: boolean
onEdit: () => void
onCancel: () => void
onSave: (value: string) => void
onClear: () => void
/** 可选:编辑态提供「检测可用模型」能力(如 LLM_MODEL 行) */
detectModels?: () => Promise<string[]>
}) {
const [value, setValue] = useState('')
const origin = ORIGIN_BADGE[setting.origin]
// 行内模型检测
const [detecting, setDetecting] = useState(false)
const [detected, setDetected] = useState<string[] | null>(null)
const [detectError, setDetectError] = useState('')
// 进入编辑态时清空上次的检测结果
useEffect(() => {
if (editing) {
setDetected(null)
setDetectError('')
}
}, [editing])
async function handleDetect() {
if (!detectModels || detecting) return
setDetecting(true)
setDetectError('')
try {
setDetected(await detectModels())
} catch (err) {
setDetected(null)
setDetectError(err instanceof Error ? err.message : '检测失败')
} finally {
setDetecting(false)
}
}
if (editing) {
return (
<div className="border-b border-ink-200 py-2.5 last:border-b-0">
<div className="mb-1.5 flex flex-wrap items-center gap-1.5 text-2xs text-ink-500">
<span className="break-all font-mono text-ink-800">{setting.key}</span>
{setting.sensitive && <Badge status="warning"></Badge>}
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<input
type={setting.sensitive ? 'password' : 'text'}
value={value}
onChange={e => setValue(e.target.value)}
placeholder={`输入新的 ${setting.label}`}
autoFocus
autoComplete="off"
className="field flex-1"
/>
<div className="flex gap-2">
<button
onClick={() => onSave(value)}
disabled={!value.trim() || busy}
className="btn btn-solid btn-sm"
>
{busy ? (<><Spinner /> </>) : '保存'}
</button>
<button onClick={onCancel} disabled={busy} className="btn btn-sm">
</button>
</div>
</div>
{detectModels && (
<div className="mt-2 space-y-2">
<button onClick={handleDetect} disabled={detecting} className="btn btn-sm">
{detecting ? (<><Spinner /> </>) : detected ? '重新检测' : '检测可用模型'}
</button>
{detectError && (
<p className="border-l-2 border-press bg-press-wash/40 px-3 py-1.5 text-2xs leading-relaxed text-press-dark">
{detectError}
</p>
)}
{detected && detected.length > 0 && (
<div className="max-h-48 overflow-y-auto border border-ink-200">
{detected.map(id => (
<button
key={id}
type="button"
onClick={() => setValue(id)}
className={`flex w-full items-center justify-between gap-3 border-b border-ink-200 px-3 py-1.5 text-left last:border-b-0 hover:bg-paper-100 ${
value === id ? 'bg-press-wash/50' : ''
}`}
>
<span className="min-w-0 break-all font-mono text-2xs text-ink-800">{id}</span>
{value === id && <Badge status="success"></Badge>}
</button>
))}
</div>
)}
{detected && detected.length === 0 && !detectError && (
<p className="text-2xs text-ink-400"></p>
)}
</div>
)}
</div>
)
}
return (
<div className="space-y-1.5 border-b border-ink-200 py-2.5 last:border-b-0">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="break-all font-mono text-2xs text-ink-800">{setting.key}</span>
<Badge status={origin.status}>{origin.text}</Badge>
</div>
<div className="break-all font-mono text-2xs leading-relaxed text-ink-500">
{setting.configured ? setting.masked : '—'}
</div>
<div className="flex justify-end gap-2">
<button onClick={onEdit} disabled={busy} className="btn btn-sm">
{setting.configured ? '更换' : '配置'}
</button>
{setting.origin === 'db' && (
<button onClick={onClear} disabled={busy} className="btn btn-sm" title="删除数据库覆盖值,回落 .env">
.env
</button>
)}
</div>
</div>
)
}
+100 -54
View File
@@ -1,66 +1,112 @@
/**
* Admin 后台管理系统 - 统一 API 客户端(门面)
* Admin 后台管理系统 - 统一 API 客户端
*
* 鉴权:通过 POST /api/v1/auth/login 用密码换取 HttpOnly Cookie 会话,
* 同源请求自动携带 Cookie,无需手动管理密钥
* 收到 401 时广播 `profeto:unauthorized` 事件,由 AdminLayout 切回登录页。
*
* 实现已收敛到共享层 lib/http.ts(超时/错误解析/401 广播只此一份),
* 本文件仅保留 Admin 侧的门面签名与认证接口,供既有页面按原路径导入。
* 写入型/高成本接口(采集、回测、结算)受 X-API-Key 保护:
* 密钥在「系统配置」页设置,存于本机 localStorage,每次请求自动附带
*/
import { http, ApiError, UNAUTHORIZED_EVENT } from '../lib/http'
/** Admin 侧兼容导出:错误类型与会话失效事件名的规范来源在 lib/http */
export { ApiError, UNAUTHORIZED_EVENT }
const API_BASE = '/api/v1'
const TIMEOUT_MS = 30_000
/** Admin 请求可覆盖项(与 lib/http RequestOptions 对齐的子集) */
type ApiOpts = {
timeoutMs?: number
/** 改密接口的 401 表示「当前密码错误」,非会话过期,置 true 跳过登出广播 */
skipAuthHandling?: boolean
const ADMIN_KEY_STORAGE = 'profeto_admin_key'
/** 读取本机保存的管理员密钥 */
export function getAdminKey(): string {
try {
return localStorage.getItem(ADMIN_KEY_STORAGE) ?? ''
} catch {
return ''
}
}
/** 保存/清除管理员密钥(传空字符串即清除) */
export function setAdminKey(key: string): void {
try {
if (key) localStorage.setItem(ADMIN_KEY_STORAGE, key)
else localStorage.removeItem(ADMIN_KEY_STORAGE)
} catch {
/* 隐私模式等场景下不可用,静默忽略 */
}
}
export class ApiError extends Error {
constructor(
message: string,
public status: number,
public data?: unknown,
) {
super(message)
this.name = 'ApiError'
}
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
// 修复: 正确拼接 API_BASE
const url = path.startsWith('http')
? path
: path.startsWith('/')
? path // 已经是绝对路径(如 /health)
: `${API_BASE}${path}`
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS)
try {
const adminKey = getAdminKey()
const res = await fetch(url, {
...options,
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
...(adminKey ? { 'X-API-Key': adminKey } : {}),
...options.headers,
},
})
if (!res.ok) {
let detail: unknown
try {
detail = await res.json()
} catch {
detail = await res.text()
}
let message =
detail && typeof detail === 'object' && 'detail' in detail
? String((detail as { detail: unknown }).detail)
: `HTTP ${res.status}: ${res.statusText}`
if (res.status === 401) {
message += '\n请在「系统配置」页填写管理员密钥后重试。'
}
throw new ApiError(message, res.status, detail)
}
// 修复: 正确判断 204 No Content
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 api = {
get: <T>(path: string) => http.get<T>(path),
post: <T>(path: string, body?: unknown, opts?: ApiOpts) =>
http.post<T>(path, body, opts),
put: <T>(path: string, body?: unknown, opts?: ApiOpts) =>
http.put<T>(path, body, opts),
delete: <T>(path: string) => http.delete<T>(path),
}
// ── 认证 ────────────────────────────────────────────────────────
/** 密码登录,成功后服务端写入 HttpOnly 会话 Cookie */
export function login(password: string): Promise<{ ok: boolean }> {
return api.post(`${API_BASE}/auth/login`, { password })
}
/** 退出登录,清除会话 Cookie */
export function logout(): Promise<{ ok: boolean }> {
return api.post(`${API_BASE}/auth/logout`)
}
/** 探测当前登录状态 */
export function fetchAuthState(): Promise<{
authenticated: boolean
enabled: boolean
password_origin?: 'db' | 'env' | 'none'
}> {
return api.get(`${API_BASE}/auth/me`)
}
/** 修改管理员密码(成功后所有会话失效,需重新登录) */
export function changePassword(currentPassword: string, newPassword: string): Promise<{ ok: boolean; message: string }> {
// skipAuthHandling: 改密接口的 401 表示「当前密码错误」,非会话过期,不要触发登出
return api.post(
`${API_BASE}/auth/change-password`,
{ current_password: currentPassword, new_password: newPassword },
{ skipAuthHandling: true },
)
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body?: unknown) =>
request<T>(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined }),
put: <T>(path: string, body?: unknown) =>
request<T>(path, { method: 'PUT', body: body ? JSON.stringify(body) : undefined }),
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
}
export { API_BASE }
+24 -124
View File
@@ -8,8 +8,6 @@
import { ReactNode } from 'react'
import { ApiError } from './api'
// ── 卡片 ────────────────────────────────────────────────────────
export function Card({
@@ -166,12 +164,12 @@ export function DataTable<T = any>({
// ── 进度条:同前台置信度细线 ────────────────────────────────────
export function ProgressBar({ value, className = '' }: { value: number; className?: string }) {
export function ProgressBar({ value }: { value: number }) {
const clamped = Math.max(0, Math.min(100, value))
return (
<div className={`h-2 w-full overflow-hidden rounded-full bg-ink-200 ${className}`} role="progressbar" aria-valuenow={clamped}>
<div className="h-px w-full bg-ink-200" role="progressbar" aria-valuenow={clamped}>
<div
className="h-full rounded-full bg-press transition-[width] duration-500"
className="h-px bg-press transition-[width] duration-500"
style={{ width: `${clamped}%` }}
/>
</div>
@@ -253,19 +251,14 @@ export function ResponsiveTable<T = any>({
export function SectionHeader({
title,
description,
action,
}: {
title: string
description?: string
action?: ReactNode
}) {
return (
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="section-head text-base">{title}</h2>
{description && <p className="mt-1.5 text-xs text-ink-500">{description}</p>}
</div>
{action}
<div className="mb-5">
<h2 className="section-head text-base">{title}</h2>
{description && <p className="mt-1.5 text-xs text-ink-500">{description}</p>}
</div>
)
}
@@ -277,31 +270,26 @@ export function Alert({
title,
message,
onClose,
action,
}: {
kind: 'error' | 'ok' | 'info' | 'warning'
kind: 'error' | 'ok' | 'info'
title: string
message?: string
onClose?: () => void
/** 右侧操作按钮(如「去修复」) */
action?: ReactNode
}) {
const style =
kind === 'error'
? 'border-press bg-press-wash'
: kind === 'warning'
? 'border-press bg-press-wash/60'
: kind === 'ok'
? 'border-ink-900 bg-paper-100'
: 'border-ink-300 bg-paper-50'
const titleCls = kind === 'error' || kind === 'warning' ? 'text-press' : 'text-ink-900'
: kind === 'ok'
? 'border-ink-900 bg-paper-100'
: 'border-ink-300 bg-paper-50'
const titleCls = kind === 'error' ? 'text-press' : 'text-ink-900'
return (
<div className={`flex items-start justify-between gap-3 border px-4 py-3 ${style}`}>
<div>
<p className={`flex items-center gap-1.5 text-sm font-medium ${titleCls}`}>
<span
className={`inline-block h-1.5 w-1.5 ${kind === 'error' || kind === 'warning' ? 'bg-press' : 'bg-ink-900'}`}
className={`inline-block h-1.5 w-1.5 ${kind === 'error' ? 'bg-press' : 'bg-ink-900'}`}
aria-hidden="true"
/>
{title}
@@ -312,70 +300,22 @@ export function Alert({
</p>
)}
</div>
<div className="flex items-center gap-2">
{action}
{onClose && (
<button
onClick={onClose}
className="text-ink-400 transition-colors hover:text-ink-900"
aria-label="关闭"
>
<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>
{onClose && (
<button
onClick={onClose}
className="text-ink-400 transition-colors hover:text-ink-900"
aria-label="关闭"
>
<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>
)
}
// ── 错误横幅:标准化错误标题/文案/建议动作 ──────────────────────
/** 根据错误对象生成标准化的错误标题、文案与建议动作。 */
export function describeError(err: unknown): { title: string; detail: string; kind: 'error' | 'warning' } {
if (err instanceof ApiError) {
const status = err.status
const apiDetail = typeof err.data === 'object' && err.data && 'detail' in (err.data as object)
? String((err.data as { detail: unknown }).detail)
: ''
const msg = apiDetail || err.message
switch (status) {
case 401:
return { title: '登录已过期', detail: '请重新登录后继续操作。', kind: 'warning' }
case 403:
return { title: '无权访问', detail: msg || '当前账号没有执行该操作的权限。', kind: 'error' }
case 429:
return { title: '请求过于频繁', detail: msg || '每分钟最多 10 次预测,请稍后再试。', kind: 'warning' }
case 502:
return { title: '上游 LLM 不可用', detail: msg || 'LLM 服务暂时不可用,请稍后重试或切换到更便宜的模型。', kind: 'error' }
case 503:
return { title: '服务未就绪', detail: msg || '服务器鉴权未配置,请联系管理员。', kind: 'error' }
case 0:
return { title: '网络错误或请求超时', detail: '请检查网络连接后重试。', kind: 'warning' }
}
if (status >= 500) {
return { title: '服务器错误', detail: msg || `HTTP ${status},请稍后重试。`, kind: 'error' }
}
return { title: '请求失败', detail: msg || `HTTP ${status}`, kind: 'error' }
}
if (err instanceof Error) {
return { title: '操作失败', detail: err.message, kind: 'error' }
}
return { title: '未知错误', detail: String(err), kind: 'error' }
}
/** 统一错误横幅:用于页面级错误展示。 */
export function ErrorBanner({
err,
onClose,
}: {
err: unknown
onClose?: () => void
}) {
const { title, detail, kind } = describeError(err)
return <Alert kind={kind} title={title} message={detail} onClose={onClose} />
}
// ── 加载指示:同前台 Spinner ────────────────────────────────────
export function Spinner({ className = '' }: { className?: string }) {
return (
@@ -396,43 +336,3 @@ export function Spinner({ className = '' }: { className?: string }) {
export function SkeletonBlock({ className = '' }: { className?: string }) {
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>
)
}
+70 -319
View File
@@ -15,15 +15,6 @@ import type {
Match,
Prediction,
EvalSummary,
DataSourceStatus,
DataSourceSetting,
DataSourceTestResult,
LLMAgentConfig,
LogEntry,
IngestSourceStatus,
MatchDetailOut,
MatchContextOut,
AdminStats,
} from './types'
// ── 仪表盘 ──────────────────────────────────────────────────────
@@ -31,24 +22,20 @@ import type {
/**
* 从多个端点聚合仪表盘数据。
* 后端暂无专用仪表盘端点,这里组合 health + 各列表端点。
*
* 注: 比赛真实总量请用 fetchAdminStats()(GET /admin/stats,
* 后端 COUNT(*) 精确计数)。此处曾用 matches items.length 近似,
* 已随仪表盘切换真实计数而移除,防止误用 100 上限的假总量。
*/
export async function fetchDashboard(): Promise<DashboardStats> {
// 并行获取各端点数据
// predictions 返回数组
const [leagues, predictions, health] = await Promise.allSettled([
const [leagues, matches, predictions, health] = await Promise.allSettled([
api.get<League[]>(`${API_BASE}/leagues`),
api.get<Prediction[]>(`${API_BASE}/predictions?limit=100`),
api.get<Match[]>(`${API_BASE}/matches?limit=1`),
api.get<Prediction[]>(`${API_BASE}/predictions?limit=1`),
api.get<{ status: string }>('/health'),
])
return {
leagues: leagues.status === 'fulfilled' ? leagues.value : [],
// predictions 直接返回数组
total_predictions: predictions.status === 'fulfilled' ? (predictions.value as any)?.length ?? 0 : 0,
total_matches: matches.status === 'fulfilled' ? (matches.value as any)?.total ?? 0 : 0,
total_predictions: predictions.status === 'fulfilled' ? (predictions.value as any)?.total ?? 0 : 0,
health: health.status === 'fulfilled' ? (health.value as any).status : 'unknown',
db_tables: [], // 后端暂无表统计端点
last_collection: [], // 后端暂无采集历史端点
@@ -59,29 +46,42 @@ export async function fetchDashboard(): Promise<DashboardStats> {
// ── 数据采集 ────────────────────────────────────────────────────
export async function triggerCollection(req: CollectionRequest): Promise<any> {
const body: Record<string, any> = {
leagues: req.leagues,
date_from: req.date_from,
date_to: req.date_to,
status: req.status || undefined,
task: req.task || 'events',
limit: req.limit || 100,
season: req.season || undefined,
const sourceMap: Record<string, { path: string; body: any }> = {
bzzoiro: {
path: `${API_BASE}/ingest/bzzoiro`,
body: {
leagues: req.leagues,
date_from: req.date_from,
date_to: req.date_to,
status: 'finished',
},
},
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),
},
},
}
return api.post(`${API_BASE}/ingest/bzzoiro`, body)
const cfg = sourceMap[req.source]
if (!cfg) throw new Error(`未知数据源: ${req.source}`)
return api.post(cfg.path, cfg.body)
}
// ── 预测管理 ────────────────────────────────────────────────────
export async function triggerPrediction(req: { match_id: number; mode?: string }): Promise<any> {
return api.post(
`${API_BASE}/predict`,
{
match_id: req.match_id,
mode: req.mode || 'multi',
},
{ timeoutMs: 300_000 },
)
return api.post(`${API_BASE}/predict`, {
match_id: req.match_id,
mode: req.mode || 'multi',
})
}
export async function fetchPredictions(limit = 50): Promise<any[]> {
@@ -91,30 +91,16 @@ export async function fetchPredictions(limit = 50): Promise<any[]> {
// ── 评估 & 回测 ─────────────────────────────────────────────────
export async function fetchEvalSummary(params: {
limit?: number
provider?: string
model?: string
prompt_version?: string
mode?: string
league_code?: string
} = {}): Promise<EvalSummary | null> {
const sp = new URLSearchParams()
if (params.limit) sp.set('limit', String(params.limit))
if (params.provider) sp.set('provider', params.provider)
if (params.model) sp.set('model', params.model)
if (params.prompt_version) sp.set('prompt_version', params.prompt_version)
if (params.mode) sp.set('mode', params.mode)
if (params.league_code) sp.set('league_code', params.league_code)
export async function fetchEvalSummary(): Promise<EvalSummary | null> {
try {
return await api.get<EvalSummary>(`${API_BASE}/eval/summary?${sp}`)
return await api.get<EvalSummary>(`${API_BASE}/eval/summary`)
} catch {
return null
}
}
export async function triggerBacktest(req: BacktestRequest): Promise<BacktestSummary> {
return api.post<BacktestSummary>(`${API_BASE}/backtest`, req, { timeoutMs: 300_000 })
return api.post<BacktestSummary>(`${API_BASE}/backtest`, req)
}
// ── 辅助数据 ────────────────────────────────────────────────────
@@ -164,125 +150,32 @@ 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}` : ''}`)
}
// ── 数据源管理 ──────────────────────────────────────────────────
/**
* 测试数据源连通性后端真实请求上游一次,不触发入库
* 测试数据源连调用采集 API 验证连通性
*/
export function testDataSourceConnection(name: string): Promise<DataSourceTestResult> {
return api.post<DataSourceTestResult>(`${API_BASE}/admin/datasources/${name}/test`)
export async function testDataSource(source: 'bzzoiro' | 'understat' | 'injuries'): Promise<any> {
const sourceMap: Record<string, { path: string; body: any }> = {
bzzoiro: { path: `${API_BASE}/ingest/bzzoiro`, body: { leagues: [], date_from: '', date_to: '', status: 'finished' } },
understat: { path: `${API_BASE}/ingest/understat`, body: { league: 'EPL', season: new Date().getFullYear() } },
injuries: { path: `${API_BASE}/ingest/injuries`, body: { date: new Date().toISOString().slice(0, 10) } },
}
const cfg = sourceMap[source]
if (!cfg) throw new Error(`未知数据源: ${source}`)
return api.post(cfg.path, cfg.body)
}
/**
* 获取数据源状态与配置(脱敏)
* 获取数据源状态 — 后端暂无专用端点,返回模拟状态
*/
export function fetchDataSourceStatuses(): Promise<DataSourceStatus[]> {
return api.get<DataSourceStatus[]>(`${API_BASE}/admin/datasources`)
}
/**
* 探测当前 LLM 服务可用模型(只读,不产生费用)
*/
export function fetchLLMModels(): Promise<{ ok: boolean; models: string[]; latency_ms?: number; detail: string }> {
return api.get(`${API_BASE}/admin/llm/models`)
}
/**
* 各专家/终裁的独立 LLM 配置状态
*/
export function fetchLLMAgents(): Promise<LLMAgentConfig[]> {
return api.get<LLMAgentConfig[]>(`${API_BASE}/admin/llm/agents`)
}
/**
* 查询系统日志(内存缓冲,最新在前)
*/
export function fetchLogs(params: { level?: string; keyword?: string; limit?: number } = {}): Promise<{ entries: LogEntry[]; count: number }> {
const sp = new URLSearchParams()
if (params.level) sp.set('level', params.level)
if (params.keyword) sp.set('keyword', params.keyword)
if (params.limit) sp.set('limit', String(params.limit))
return api.get<{ entries: LogEntry[]; count: number }>(`${API_BASE}/admin/logs?${sp}`)
}
/**
* 全部可配置项(脱敏),供各配置页渲染
*/
export function fetchSettings(): Promise<DataSourceSetting[]> {
return api.get<DataSourceSetting[]>(`${API_BASE}/admin/settings`)
}
/**
* 更新配置项(写入 app_settings,覆盖 .env,立即生效)
*/
export function updateSetting(key: string, value: string) {
return api.put<{ key: string; masked: string; origin: string }>(`${API_BASE}/admin/settings/${key}`, { value })
}
/**
* 清除配置项的 DB 覆盖值,回落 .env
*/
export function clearSetting(key: string) {
return api.delete<{ key: string; masked: string; origin: string }>(`${API_BASE}/admin/settings/${key}`)
export async function fetchDataSourceStatuses(): Promise<any[]> {
// 后端暂无专用配置端点,返回静态信息
return [
{ name: 'bzzoiro', label: 'Bzzoiro', keyConfigured: true, maskedKey: 'bz***xxx', lastIngestion: null, status: 'configured' },
{ name: 'understat', label: 'Understat', keyConfigured: true, maskedKey: '无需 Key', lastIngestion: null, status: 'configured' },
{ name: 'injuries', label: 'Injuries', keyConfigured: true, maskedKey: 'inj***xxx', lastIngestion: null, status: 'configured' },
]
}
// ── LLM 配置 ────────────────────────────────────────────────────
@@ -291,17 +184,10 @@ export function clearSetting(key: string) {
* 测试 LLM 连接 — 调用预测端点验证
*/
export async function testLLMConnection(matchId?: number): Promise<any> {
// F4 修复: 优先使用不依赖比赛的 ping 端点
try {
return await api.post(`${API_BASE}/admin/llm/ping`, {})
} catch {
// 回退到旧方式(兼容)
return api.post(
`${API_BASE}/predict`,
{ match_id: matchId || 1, mode: 'single' },
{ timeoutMs: 300_000 },
)
}
return api.post(`${API_BASE}/predict`, {
match_id: matchId || 1,
mode: 'single',
})
}
/**
@@ -337,151 +223,16 @@ export async function fetchLLMUsageStats(): Promise<any> {
// ── 系统配置 ────────────────────────────────────────────────────
/**
* P2-9 修复: 获取系统配置列表,从后端 /admin/settings 读取真实值(脱敏)。
* 字段名对齐 Config.tsx 中使用的 { key, value_masked, description, is_sensitive } 格式。
* 获取系统配置列表 — 后端暂无配置端点,返回静态信息
*/
export async function fetchSystemConfig(): Promise<any[]> {
try {
const settings = await fetchSettings()
return settings.map(s => ({
key: s.key,
value_masked: s.masked,
description: s.description,
is_sensitive: s.sensitive,
}))
} catch {
// 后端不可用时返回空列表,Config.tsx 会显示空状态
return []
}
}
/**
* 数据源健康/最近采集状态(只读,不触发采集)
*/
export function fetchIngestStatus(): Promise<{ sources: IngestSourceStatus[] }> {
return api.get<{ sources: IngestSourceStatus[] }>(`${API_BASE}/admin/ingest/status`)
}
/**
* 比赛详情(含最近预测摘要)
*/
export function fetchMatchDetail(id: number): Promise<MatchDetailOut> {
return api.get<MatchDetailOut>(`${API_BASE}/matches/${id}`)
}
/**
* 比赛上下文(双方近况 + 历史交锋,只读)
*/
export function fetchMatchContext(id: number): Promise<MatchContextOut> {
return api.get<MatchContextOut>(`${API_BASE}/matches/${id}/context`)
}
/**
* 管理区统计(只读):近 24h/7d 预测次数
*/
export function fetchAdminStats(): Promise<AdminStats> {
return api.get<AdminStats>(`${API_BASE}/admin/stats`)
}
// ── 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`)
return [
{ key: 'LLM_PROVIDER', value_masked: 'openai', description: 'LLM 提供商', is_sensitive: false },
{ key: 'LLM_MODEL', value_masked: 'gpt-4o', description: 'LLM 模型', is_sensitive: false },
{ key: 'LLM_BASE_URL', value_masked: 'https://api.openai.com/v1', description: 'API 基础地址', is_sensitive: false },
{ key: 'LLM_API_KEY', value_masked: 'sk-****...****', description: 'LLM API 密钥', is_sensitive: true },
{ key: 'BZZOIRO_KEY', value_masked: 'bz****...****', description: 'Bzzoiro 数据源密钥', is_sensitive: true },
{ key: 'DATABASE_URL', value_masked: 'postgresql://****@localhost/profeto', description: '数据库连接', is_sensitive: true },
{ key: 'LOG_LEVEL', value_masked: 'INFO', description: '日志级别', is_sensitive: false },
]
}
+22 -88
View File
@@ -12,17 +12,14 @@
import { useEffect, useState } from 'react'
import { triggerBacktest, fetchEvalSummary, fetchLeagues } from '../dal'
import type { BacktestRequest, BacktestSummary, EvalSummary, League } from '../types'
import type { BacktestRequest, EvalSummary, League } from '../types'
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
import TeamSideTag from '../../components/TeamSideTag'
interface BacktestResultRow {
match_id: number
league_code?: string | null
home_team: string
away_team: string
home_team_zh?: string | null
away_team_zh?: string | null
match_date?: string | null
actual_score: string
actual_1x2?: string
@@ -34,46 +31,21 @@ interface BacktestResultRow {
}
interface BacktestResponse {
summary: BacktestSummary
summary: {
total: number
scored: number
accuracy_1x2?: number
avg_score_rmse?: number
avg_subjective_confidence?: number
}
results: BacktestResultRow[]
}
const OUTCOME_LABEL: Record<string, string> = { '1': '主胜', X: '平局', '2': '客胜' }
/** 导出回测明细为 CSV(UTF-8 BOM,Excel 可直接打开) */
function exportCsv(rows: BacktestResultRow[]) {
const header = [
"比赛日期", "联赛", "主队", "客队", "实际比分", "实际1X2",
"预测主球", "预测客球", "预测1X2", "主观置信度", "1X2命中",
]
const lines = [header.join(",")]
for (const r of rows) {
lines.push([
fmtDate(r.match_date), r.league_code ?? "",
csvCell(r.home_team_zh || r.home_team), csvCell(r.away_team_zh || r.away_team),
r.actual_score, r.actual_1x2 ?? "",
r.pred_home ?? "", r.pred_away ?? "", r.pred_1x2 ?? "",
r.subjective_confidence != null ? String(Math.round(r.subjective_confidence * 100)) : "",
r.correct_1x2 ? "是" : "否",
].join(","))
}
const blob = new Blob(["" + lines.join("\n")], { type: "text/csv;charset=utf-8" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = `backtest_${new Date().toLocaleDateString('sv-SE')}.csv`
a.click()
URL.revokeObjectURL(url)
}
/** CSV 字段转义:含逗号/引号/换行时加引号 */
function csvCell(v: string): string {
return /[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v
}
function fmtDate(s?: string | null): string {
if (!s) return '—'
return new Date(s).toLocaleDateString('sv-SE')
return s.slice(0, 10)
}
export default function BacktestPage() {
@@ -82,8 +54,7 @@ export default function BacktestPage() {
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [limit, setLimit] = useState(20)
const [mode] = useState<'multi'>('multi')
const [model, setModel] = useState('')
const [mode, setMode] = useState<'single' | 'multi'>('single')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [result, setResult] = useState<BacktestResponse | null>(null)
@@ -116,7 +87,6 @@ export default function BacktestPage() {
date_to: dateTo || undefined,
limit,
mode,
model: model.trim() || undefined,
}
const res = await triggerBacktest(req as BacktestRequest)
setResult(res as unknown as BacktestResponse)
@@ -193,28 +163,17 @@ export default function BacktestPage() {
</div>
<div>
<label className="mb-1.5 block text-xs text-ink-500"></label>
<input
type="text"
value="多专家 (5 路 + 终裁)"
readOnly
className="field w-full bg-paper-100 text-ink-500"
/>
<select
value={mode}
onChange={e => setMode(e.target.value as 'single' | 'multi')}
className="field w-full"
>
<option value="single"> ()</option>
<option value="multi"> (,)</option>
</select>
</div>
</div>
<div>
<label className="mb-1.5 block text-xs text-ink-500">
(,= <code className="font-mono">gpt-4o</code>)
</label>
<input
type="text"
value={model}
onChange={e => setModel(e.target.value)}
placeholder="如 deepseek-chat / 留空使用默认"
className="field w-full"
/>
</div>
{error && <Alert kind="error" title="回测失败" message={error} onClose={() => setError(null)} />}
<button type="submit" disabled={loading} className="btn btn-solid w-full">
@@ -228,31 +187,15 @@ export default function BacktestPage() {
<div className="space-y-6">
{summary && (
<Card>
<CardHeader
title="回测结果"
description={`模式: ${mode}${model ? ` · 模型: ${model}` : ''} · 限 ${limit}`}
action={
result?.results?.length
? (<button onClick={() => exportCsv(result.results)} className="btn btn-sm"> CSV</button>)
: undefined
}
/>
<CardHeader title="回测结果" />
<CardBody>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div>
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">
{summary.scored}/{summary.total}
</div>
<div className="mt-1 text-2xs text-ink-400"> / </div>
</div>
<div>
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">
{summary.success}/{summary.degraded}
</div>
<div className="mt-1 text-2xs text-ink-400">
/ <span className="text-ink-300"> (degraded)</span>
</div>
</div>
<div>
<div className="font-serif text-2xl font-bold tabular-nums text-press">
{summary.accuracy_1x2 !== undefined && summary.accuracy_1x2 !== null
@@ -277,11 +220,6 @@ export default function BacktestPage() {
</div>
<div className="mt-1 text-2xs text-ink-400"></div>
</div>
{summary.degraded > 0 && (
<div className="col-span-full border-l-2 border-press bg-press-wash/40 px-3 py-2 text-2xs leading-relaxed text-press-dark">
{summary.degraded} (),
</div>
)}
</div>
</CardBody>
</Card>
@@ -341,12 +279,8 @@ export default function BacktestPage() {
<span className="w-24 flex-shrink-0 text-2xs tabular-nums text-ink-400">
{fmtDate(r.match_date)}
</span>
<span className="flex min-w-0 flex-1 items-center gap-1.5 text-sm text-ink-800">
<TeamSideTag side="home" />
<span className="truncate">{r.home_team_zh || r.home_team}</span>
<span className="flex-shrink-0 text-ink-300">vs</span>
<TeamSideTag side="away" />
<span className="truncate">{r.away_team_zh || r.away_team}</span>
<span className="min-w-0 flex-1 truncate text-sm text-ink-800">
{r.home_team} vs {r.away_team}
</span>
<span className="flex-shrink-0 text-xs tabular-nums text-ink-500">
<span className="font-serif font-bold text-ink-900">{r.actual_score}</span>
+95 -224
View File
@@ -1,62 +1,56 @@
/**
* Admin 后台 - 数据采集页面(bzzoiro 单一数据源)
*
* 三个采集任务:
* events — 比赛日程与比分
* standings — 联赛积分榜
* stats — 已完赛比赛详细统计回填
* all — 依次执行以上三项
* Admin 后台 - 数据采集页面(报刊风)
*
* 响应式布局: 移动端单列,桌面端双列
*/
import { useEffect, useState, useCallback, useRef } from 'react'
import { triggerCollection, fetchLeagues, fetchIngestStatus } from '../dal'
import type { IngestSourceStatus } from '../types'
import { useEffect, useState, useCallback } from 'react'
import { triggerCollection, fetchLeagues } from '../dal'
import type { CollectionRequest, League } from '../types'
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
const TASKS = [
{ value: 'events', label: '比赛数据', desc: '赛程 / 比分 / 未开赛安排', icon: '⚽' },
{ value: 'standings', label: '积分榜', desc: '联赛排名 / 积分 / xG差 / 近期走势', icon: '🏆' },
{ value: 'stats', label: '统计回填', desc: '已完赛比赛的 xG / 射门 / 控球等详细统计', icon: '📊' },
{ value: 'all', label: '全量采集', desc: '依次采集比赛 + 积分榜 + 统计回填', icon: '⏵⏵' },
const SOURCES = [
{ value: 'bzzoiro', label: 'Bzzoiro', desc: '历史赛程与比分' },
{ value: 'understat', label: 'Understat', desc: 'xG 进阶数据' },
{ value: 'injuries', label: 'Injuries', desc: '球员伤停' },
] 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() {
const [leagues, setLeagues] = useState<League[]>([])
const [task, setTask] = useState<string>('events')
const [source, setSource] = useState<string>('bzzoiro')
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 [dateTo, setDateTo] = useState('')
const [season, setSeason] = useState('')
const [ingestStatus, setIngestStatus] = useState('')
const [limit, setLimit] = useState(100)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<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 lg = await fetchLeagues()
setLeagues(lg)
@@ -64,73 +58,35 @@ export default function CollectionPage() {
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) {
e.preventDefault()
setError(null)
setResult(null)
setLoading(true)
setTaskStatus('running')
setTaskStartedAt(Date.now())
try {
const body: CollectionRequest = {
source: 'bzzoiro',
source: source as CollectionRequest['source'],
leagues: leagueCode ? [leagueCode] : undefined,
task: task as CollectionRequest['task'],
limit,
league: leagueCode || undefined,
season: season || undefined,
status: ingestStatus || undefined,
date_from: isEventsTask ? dateFrom || undefined : undefined,
date_to: isEventsTask ? dateTo || undefined : undefined,
date_from: dateFrom || undefined,
date_to: dateTo || undefined,
}
await triggerCollection(body)
setResult({
title: '采集任务已启动',
detail: '正在后台执行(上游限速时可能需要数分钟)。完成结果与错误请到「系统日志」页查看(支持自动刷新)。',
})
// 启动轮询,跟踪状态
startPolling()
// 30 秒后自动停止轮询并标记完成
setTimeout(() => {
setTaskStatus('done')
stopPolling()
}, 30_000)
const res = await triggerCollection(body)
setResult(summarizeResult(res, source))
} catch (err: unknown) {
setTaskStatus('error')
setError(err instanceof Error ? err.message : '采集触发失败')
stopPolling()
} finally {
setLoading(false)
}
}
const elapsed = taskStartedAt ? Math.round((Date.now() - taskStartedAt) / 1000) : 0
return (
<div className="space-y-6">
<SectionHeader
title="数据采集"
description="bzzoiro 单一数据源:比赛数据、积分榜、比赛统计三条管线。采集为后台异步执行。"
description="触发数据源采集,支持联赛筛选和日期范围。采集为同步执行,大范围日期耗时较长。"
/>
<div className="grid gap-6 lg:grid-cols-2">
@@ -139,26 +95,20 @@ export default function CollectionPage() {
<CardHeader title="新建采集任务" />
<CardBody>
<form onSubmit={handleSubmit} className="space-y-4">
{/* 任务类型 */}
{/* 数据源选择 */}
<div>
<label className="mb-1.5 block text-xs text-ink-500"></label>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{TASKS.map(t => (
<button
key={t.value}
type="button"
onClick={() => setTask(t.value)}
className={`rounded-lg border px-3 py-2 text-left text-xs transition-colors ${
task === t.value
? '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>
<label className="mb-1.5 block text-xs text-ink-500"></label>
<select
value={source}
onChange={e => setSource(e.target.value)}
className="field w-full"
>
{SOURCES.map(s => (
<option key={s.value} value={s.value}>
{s.label} {s.desc}
</option>
))}
</div>
</select>
</div>
{/* 联赛选择 */}
@@ -176,73 +126,41 @@ export default function CollectionPage() {
</select>
</div>
{/* events/all 任务专用: 比赛状态 + 日期 */}
{isEventsTask && (
<>
<div>
<label className="mb-1.5 block text-xs text-ink-500"></label>
<select
value={ingestStatus}
onChange={e => setIngestStatus(e.target.value)}
className="field w-full"
>
<option value="">( + )</option>
<option value="finished"></option>
<option value="scheduled"></option>
</select>
</div>
<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>
</>
)}
{/* standings 任务专用: 赛季 */}
{(task === 'standings') && (
{/* Understat 专用: 赛季 */}
{source === 'understat' && (
<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
type="text"
type="number"
value={season}
onChange={e => setSeason(e.target.value)}
placeholder="2026-2027"
placeholder="2025"
className="field w-full"
/>
</div>
)}
{/* stats 任务专用: 回填数量 */}
{(task === 'stats') && (
<div>
<label className="mb-1.5 block text-xs text-ink-500">(1-500)</label>
<input
type="number"
min={1}
max={500}
value={limit}
onChange={e => setLimit(parseInt(e.target.value) || 100)}
className="field w-full"
/>
<p className="mt-1 text-2xs text-ink-400">
source_event_id (), 1.2 /
</p>
{/* 日期范围 */}
{source !== 'injuries' && (
<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>
)}
@@ -265,74 +183,27 @@ export default function CollectionPage() {
</CardBody>
</Card>
{/* 任务状态 + 数据源说明 */}
<div className="space-y-6">
{/* 任务进度 */}
<Card>
<CardHeader title="任务状态" />
<CardBody>
{taskStatus === 'idle' && (
<p className="text-xs text-ink-400"></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>
{/* 数据源说明 */}
<Card>
<CardHeader title="数据源说明" />
<CardBody>
<div className="space-y-3">
{SOURCES.map(s => (
<div key={s.value} className="border-b border-ink-200 px-1 py-3 last:border-b-0">
<div className="flex items-center gap-3">
<Badge status="info">{s.label}</Badge>
<p className="text-xs text-ink-600">{s.desc}</p>
</div>
<p className="text-2xs text-ink-400">
,
</p>
</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>
))}
</div>
{/* 数据源说明 */}
<Card>
<CardHeader title="采集任务说明" />
<CardBody>
<div className="space-y-3">
{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>
<p className="mt-4 border-l-2 border-ink-300 pl-3 text-2xs leading-relaxed text-ink-500">
ADMIN_API_KEY,
401
</p>
</CardBody>
</Card>
</div>
</div>
)
+57 -101
View File
@@ -2,27 +2,24 @@
* Admin 后台 - 系统配置管理页面(报刊风)
*
* 功能:
* - 登录与鉴权说明(ADMIN_PASSWORD,HttpOnly 会话 Cookie)
* - 管理员密钥(X-API-Key):存本机浏览器,自动附带到采集/回测/结算等受保护接口
* - 显示当前 .env 配置(脱敏;后端暂无配置端点,为静态说明)
* - 配置修改指南
*/
import { useEffect, useState, useCallback } from 'react'
import { fetchSystemConfig } from '../dal'
import { changePassword, fetchAuthState, UNAUTHORIZED_EVENT } from '../api'
import { getAdminKey, setAdminKey } from '../api'
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components'
export default function ConfigPage() {
const [config, setConfig] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const [passwordOrigin, setPasswordOrigin] = useState<'db' | 'env' | 'none' | null>(null)
// 修改密码表单
const [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 [adminKey, setAdminKeyInput] = useState('')
const [keySaved, setKeySaved] = useState(false)
const [keyExists, setKeyExists] = useState(false)
const loadConfig = useCallback(async () => {
setLoading(true)
@@ -38,115 +35,75 @@ export default function ConfigPage() {
useEffect(() => {
loadConfig()
fetchAuthState()
.then(s => setPasswordOrigin(s.password_origin ?? null))
.catch(() => setPasswordOrigin(null))
const stored = getAdminKey()
setKeyExists(stored !== '')
}, [loadConfig])
async function handleChangePassword(e: React.FormEvent) {
function handleSaveKey(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)
}
setAdminKey(adminKey.trim())
setKeyExists(adminKey.trim() !== '')
setKeySaved(true)
setAdminKeyInput('')
setTimeout(() => setKeySaved(false), 3000)
}
function handleClearKey() {
setAdminKey('')
setAdminKeyInput('')
setKeyExists(false)
}
return (
<div className="space-y-6">
<SectionHeader
title="系统配置"
description="登录鉴权说明与系统参数查看。敏感配置一律通过服务器 .env 文件管理。"
description="管理员密钥管理与系统参数查看。敏感配置一律通过服务器 .env 文件管理。"
/>
{/* 登录与鉴权 */}
{/* 管理员密钥 */}
<Card>
<CardHeader
title="登录与鉴权"
description="本后台通过密码登录保护,会话以 HttpOnly Cookie 保存,有效期默认 7 天"
title="管理员密钥 (X-API-Key)"
description="后端设置 ADMIN_API_KEY 后,采集 / 回测 / 结算等接口需要此密钥"
/>
<CardBody>
<Alert kind="ok" title="已通过密码登录" />
<p className="mt-3 text-2xs leading-relaxed text-ink-500">
<code className="font-mono">.env</code> {' '}
<code className="font-mono">ADMIN_PASSWORD</code>(),{' '}
<code className="font-mono">scrypt</code> ,
,,
<code className="font-mono">ADMIN_API_KEY</code>( X-API-Key)
</p>
{passwordOrigin && (
<p className="mt-2 flex items-center gap-2 text-2xs text-ink-500">
:
{passwordOrigin === 'db' ? (
<Badge status="success">(scrypt )</Badge>
) : passwordOrigin === 'env' ? (
<Badge status="info">.env </Badge>
) : (
<Badge status="error"></Badge>
)}
</p>
)}
{/* 修改密码表单 */}
<form onSubmit={handleChangePassword} className="mt-5 space-y-3 border-t border-ink-200 pt-4">
<div className="grid gap-3 sm:grid-cols-3">
<div>
<label className="mb-1 block text-2xs text-ink-500"></label>
<input
type="password"
value={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"
/>
<form onSubmit={handleSaveKey} className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row">
<input
type="password"
value={adminKey}
onChange={e => setAdminKeyInput(e.target.value)}
placeholder={keyExists ? '••••••••(已保存,输入新值可更换)' : '粘贴 ADMIN_API_KEY'}
autoComplete="off"
className="field flex-1"
/>
<div className="flex gap-2">
<button type="submit" disabled={!adminKey.trim()} className="btn btn-solid">
</button>
<button
type="button"
onClick={handleClearKey}
disabled={!keyExists}
className="btn btn-sm"
>
</button>
</div>
</div>
{pwdNotice && (
<Alert kind={pwdNotice.ok ? 'ok' : 'error'} title={pwdNotice.text} />
{keySaved && <Alert kind="ok" title="密钥已保存,后续请求将自动附带" />}
{keyExists && !keySaved && (
<p className="text-2xs text-ink-500">
:<Badge status="success"></Badge>
<span className="ml-2">,</span>
</p>
)}
<div className="flex items-center justify-between gap-2">
<p className="text-2xs text-ink-400">退,</p>
<button
type="submit"
disabled={pwdBusy || !currentPwd || !newPwd || !confirmPwd}
className="btn btn-solid btn-sm flex-shrink-0"
>
{pwdBusy ? (<><Spinner /> </>) : '修改密码'}
</button>
</div>
<p className="border-l-2 border-ink-300 pl-3 text-2xs leading-relaxed text-ink-500">
.env ADMIN_API_KEY ()
401
</p>
</form>
</CardBody>
</Card>
@@ -246,8 +203,7 @@ docker compose logs -f api`}
['LLM_API_KEY', 'LLM 服务商的 API 密钥,用于调用大模型'],
['LLM_MODEL', '使用的模型名称,如 gpt-4o、claude-3-5-sonnet'],
['LLM_BASE_URL', 'API 基础地址,支持兼容 OpenAI 协议的服务商'],
['ADMIN_PASSWORD', '管理后台登录密码,修改后重启 api 容器生效'],
['ADMIN_API_KEY', '脚本直连接口的鉴权密钥(请求头 X-API-Key)'],
['ADMIN_API_KEY', '管理后台写接口的鉴权密钥,配置后需在本页保存到浏览器'],
['BZZOIRO_KEY', 'Bzzoiro 数据源 API 密钥'],
['DATABASE_URL', 'PostgreSQL 数据库连接字符串'],
].map(([key, desc]) => (
+112 -149
View File
@@ -1,178 +1,141 @@
/**
* Admin 后台 - 仪表盘(报刊风)
*
* 展示:
* - 数据流水线状态(采集 → 预测 → 评估,每步的实际数据量)
* - 近期预测活动(24h / 7d / 总计)
* - 快捷操作入口(带工作流引导)
* 响应式: 移动端 1 列 → 平板 2 列 → 桌面 4 列
*/
import { useEffect, useState, useCallback } from 'react'
import { fetchAdminStats, fetchIngestStatus, fetchDashboard } from '../dal'
import type { AdminStats, IngestSourceStatus, DashboardStats } from '../types'
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: '◈' },
]
import { useEffect, useState } from 'react'
import { fetchDashboard, fetchHealth } from '../dal'
import type { DashboardStats } from '../types'
import { Card, CardBody, CardHeader, StatCard, Alert, SkeletonBlock } from '../components'
export default function Dashboard() {
const [stats, setStats] = useState<AdminStats | null>(null)
const [ingest, setIngest] = useState<IngestSourceStatus[]>([])
const [dash, setDash] = useState<DashboardStats | null>(null)
const [data, setData] = useState<DashboardStats | null>(null)
const [health, setHealth] = useState<any>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const load = useCallback(async () => {
useEffect(() => {
let active = true
setLoading(true)
const [s, i, d] = await Promise.allSettled([
fetchAdminStats(),
fetchIngestStatus(),
fetchDashboard(),
])
if (s.status === 'fulfilled') setStats(s.value)
if (i.status === 'fulfilled') setIngest(i.value.sources)
if (d.status === 'fulfilled') setDash(d.value)
setLoading(false)
Promise.all([fetchDashboard(), fetchHealth()])
.then(([stats, h]) => {
if (active) {
setData(stats)
setHealth(h)
}
})
.catch((err: unknown) => {
if (active) setError(err instanceof Error ? err.message : '加载失败')
})
.finally(() => {
if (active) setLoading(false)
})
return () => { active = false }
}, [])
useEffect(() => { load() }, [load])
if (error) {
return (
<div className="space-y-4">
<Alert kind="error" title="加载仪表盘失败" message={error} />
<button onClick={() => window.location.reload()} className="btn btn-sm">
</button>
</div>
)
}
const sourceByName = Object.fromEntries(ingest.map(s => [s.name, s]))
const bzzoiro = sourceByName['bzzoiro']
const healthText =
health?.status === 'healthy' || health?.status === 'ok' ? '正常' : '异常'
return (
<div className="space-y-6">
{/* ── 工作流引导(采集 → 预测 → 评估) ── */}
<div className="grid gap-4 sm:grid-cols-3">
{STEPS.map((s, i) => (
<a
key={s.to}
href={s.to}
className="group border border-ink-300 p-4 transition-colors hover:border-ink-900 hover:bg-paper-100"
>
<div className="flex items-center gap-2.5">
<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 className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
label="健康状态"
value={loading ? '—' : healthText}
hint={loading ? undefined : '每 60 秒随报眉自动复检'}
/>
<StatCard label="联赛数" value={loading ? '—' : data?.leagues.length ?? 0} />
<StatCard label="比赛数" value={loading ? '—' : data?.total_matches ?? 0} />
<StatCard label="预测数" value={loading ? '—' : data?.total_predictions ?? 0} />
</div>
{/* ── 数据源健康一览 ── */}
{/* 联赛列表 */}
<Card>
<CardHeader
title="数据源健康"
description="各源最近采集时间与数据量(只读快照,详细配置见「数据源」页)"
/>
<CardBody className="px-0">
{loading ? (
<div className="space-y-2 px-4 sm:px-5">
{[1, 2, 3].map(i => <SkeletonBlock key={i} className="h-8 w-full" />)}
</div>
) : (
<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 的调用量统计" />
<CardHeader title="已入库联赛" />
<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>
{loading ? (
<div className="flex flex-wrap gap-2">
{dash.leagues.map(l => (
<span key={l.code} className="border border-ink-200 px-2.5 py-1 text-xs text-ink-700">
{[1, 2, 3, 4].map(i => (
<SkeletonBlock key={i} className="h-6 w-24" />
))}
</div>
) : data && data.leagues.length > 0 ? (
<div className="flex flex-wrap gap-2">
{data.leagues.map(l => (
<span
key={l.code}
className="border border-ink-200 px-2.5 py-1 text-xs text-ink-700"
>
{l.name_zh || l.name}
<span className="ml-1.5 font-mono text-2xs text-ink-400">{l.code}</span>
</span>
))}
</div>
</CardBody>
</Card>
)}
) : (
<p className="text-xs text-ink-500">
,
</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>
)
}
@@ -1,241 +0,0 @@
/**
* 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>
)
}
-199
View File
@@ -1,199 +0,0 @@
/**
* 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>
)
}
+63 -288
View File
@@ -1,206 +1,59 @@
import { useEffect, useState, useCallback } from 'react'
import {
fetchDataSourceStatuses,
fetchIngestStatus,
fetchAdminStats,
fetchKeyRingStatus,
resetKeyRingCooldown,
updateSetting,
clearSetting,
testDataSourceConnection,
} from '../dal'
import type { DataSourceStatus, DataSourceTestResult, IngestSourceStatus, AdminStats } from '../types'
import type { KeyRingStatusResponse } from '../dal'
import SettingRow from '../SettingRow'
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components'
/**
* Admin 后台 - 数据源管理页面(报刊风)
*
* 功能:
* - 显示当前数据源状态 (bzzoiro / understat / injuries)
* - 显示 API Key 配置状态(脱敏显示)
* - 测试连接按钮(调用采集 API 验证)
* - 数据源说明
*/
function formatTime(iso: string | null): string {
if (!iso) return '暂无记录'
try {
return new Date(iso).toLocaleString('zh-CN', { hour12: false })
} catch {
return iso
}
}
import { useEffect, useState, useCallback } from 'react'
import { fetchDataSourceStatuses, testDataSource } from '../dal'
import type { DataSourceStatus } from '../types'
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components'
export default function DataSourcesPage() {
const [sources, setSources] = useState<DataSourceStatus[]>([])
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState('')
const [ingestStats, setIngestStats] = useState<Record<string, IngestSourceStatus>>({})
const [ingestLoading, setIngestLoading] = useState(true)
const [stats, setStats] = useState<AdminStats | null>(null)
const [testingSource, setTestingSource] = useState<string | null>(null)
const [testResults, setTestResults] = useState<Record<string, DataSourceTestResult>>({})
const [editingKey, setEditingKey] = useState<string | null>(null)
const [busyKey, setBusyKey] = useState<string | null>(null)
const [rowNotice, setRowNotice] = useState<{ key: string; ok: boolean; text: string } | null>(null)
const [keyRing, setKeyRing] = useState<KeyRingStatusResponse | null>(null)
const [ringLoading, setRingLoading] = useState(false)
const [testResults, setTestResults] = useState<Record<string, { success: boolean; message: string }>>({})
const loadSources = useCallback(async () => {
setLoading(true)
setLoadError('')
try {
setSources(await fetchDataSourceStatuses())
} catch (err) {
setLoadError(err instanceof Error ? err.message.split('\n')[0] : '加载失败')
const data = await fetchDataSourceStatuses()
setSources(data)
} catch {
setSources([])
} finally {
setLoading(false)
}
}, [])
// 健康状态(只读,与配置加载并行;失败不阻塞配置页)
const loadIngest = useCallback(async () => {
setIngestLoading(true)
try {
const { sources } = await fetchIngestStatus()
setIngestStats(Object.fromEntries(sources.map(x => [x.name, x])))
} catch {
/* ignore */
} finally {
setIngestLoading(false)
}
}, [])
// 管理区统计(只读)
const loadStats = useCallback(async () => {
try {
setStats(await fetchAdminStats())
} catch {
/* ignore */
}
}, [])
// Key Ring 状态(只读)
const loadKeyRing = useCallback(async () => {
setRingLoading(true)
try {
setKeyRing(await fetchKeyRingStatus())
} catch {
/* ignore */
} finally {
setRingLoading(false)
}
}, [])
useEffect(() => {
loadSources()
loadIngest()
loadStats()
loadKeyRing()
}, [loadSources, loadIngest, loadStats, loadKeyRing])
useEffect(() => { loadSources() }, [loadSources])
async function handleTest(sourceName: string) {
setTestingSource(sourceName)
setTestResults(prev => ({ ...prev, [sourceName]: { ok: false, status: null, latency_ms: 0, detail: '测试中...' } }))
setTestResults(prev => ({ ...prev, [sourceName]: { success: false, message: '测试中...' } }))
try {
const result = await testDataSourceConnection(sourceName)
setTestResults(prev => ({ ...prev, [sourceName]: result }))
await testDataSource(sourceName as 'bzzoiro' | 'understat' | 'injuries')
setTestResults(prev => ({ ...prev, [sourceName]: { success: true, message: '连接成功' } }))
} catch (err: unknown) {
const msg = err instanceof Error ? err.message.split('\n')[0] : '连接失败'
setTestResults(prev => ({ ...prev, [sourceName]: { ok: false, status: null, latency_ms: 0, detail: msg } }))
const msg = err instanceof Error ? err.message : '连接失败'
setTestResults(prev => ({ ...prev, [sourceName]: { success: false, message: msg } }))
} finally {
setTestingSource(null)
}
}
// 渲染数据源健康块(最近采集 + 异常提示)
function renderHealth(sourceName: string) {
const st = ingestStats[sourceName]
if (!st) {
return (
<div className="flex items-center justify-between text-xs">
<span className="text-ink-400"></span>
<span className="text-ink-400">{ingestLoading ? '加载中...' : '暂无数据'}</span>
</div>
)
}
const ago = st.last_success_at ? formatTime(st.last_success_at) : '暂无记录'
const issues: string[] = []
if (st.status === 'key_not_configured') issues.push('未配置 API Key')
else if (st.status === 'no_data') issues.push('本地无数据,建议补采')
if (st.last_failure) issues.push('近期有采集失败')
return (
<div className="space-y-1.5 border-t border-ink-200 pt-3">
<div className="flex items-center justify-between text-xs">
<span className="text-ink-400"></span>
<span className="text-ink-600">{ago}</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-ink-400">()</span>
<span className="text-ink-600">{st.recent_count.toLocaleString()} </span>
</div>
{st.note && <p className="text-2xs leading-relaxed text-ink-400">{st.note}</p>}
{issues.length > 0 && (
<p className="border-l-2 border-press bg-press-wash/40 px-2 py-1 text-2xs leading-relaxed text-press-dark">
{issues.join(' / ')}
</p>
)}
{st.last_failure && (
<p className="truncate text-2xs text-ink-400" title={st.last_failure.detail}>
: {st.last_failure.detail.slice(0, 60)}
</p>
)}
</div>
)
}
async function handleSave(key: string, value: string) {
setBusyKey(key)
setRowNotice(null)
try {
await updateSetting(key, value)
setRowNotice({ key, ok: true, text: '已保存,立即生效' })
setEditingKey(null)
await loadSources()
} catch (err) {
setRowNotice({ key, ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '保存失败' })
} finally {
setBusyKey(null)
}
}
async function handleClear(key: string) {
setBusyKey(key)
setRowNotice(null)
try {
await clearSetting(key)
setRowNotice({ key, ok: true, text: '已清除数据库覆盖,回落 .env 默认值' })
await loadSources()
} catch (err) {
setRowNotice({ key, ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '清除失败' })
} finally {
setBusyKey(null)
}
}
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 (
<div className="space-y-6">
<SectionHeader
title="数据源管理"
description="数据采集源的 API 配置、健康状态与连通性测试。"
description="数据采集源的配置状态与连通性测试。"
/>
{loadError && (
<Alert kind="error" title="无法加载数据源配置" message={loadError} />
)}
{/* 数据源卡片 */}
{loading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
@@ -220,53 +73,39 @@ export default function DataSourcesPage() {
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{sources.map(source => {
const result = testResults[source.name]
const cardKeys = source.settings.map(s => s.key)
return (
<Card key={source.name}>
<CardBody className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-x-2 gap-y-1 border-b border-ink-200 pb-3">
{/* 头部 */}
<div className="flex items-center justify-between border-b border-ink-200 pb-3">
<h3 className="font-serif text-sm font-bold text-ink-900">{source.label}</h3>
<Badge status={source.key_configured ? 'success' : 'error'}>
{source.key_configured ? '已就绪' : '配置'}
<Badge status={source.keyConfigured ? 'success' : 'error'}>
{source.keyConfigured ? '已配置' : '配置'}
</Badge>
</div>
<p className="text-2xs leading-relaxed text-ink-500">{source.description}</p>
{source.settings.length > 0 ? (
<div>
{source.settings.map(setting => (
<SettingRow
key={setting.key}
setting={setting}
editing={editingKey === setting.key}
busy={busyKey === setting.key}
onEdit={() => { setEditingKey(setting.key); setRowNotice(null) }}
onCancel={() => setEditingKey(null)}
onSave={v => handleSave(setting.key, v)}
onClear={() => handleClear(setting.key)}
/>
))}
{/* API Key 状态 */}
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="text-ink-400">API Key</span>
<span className="font-mono text-ink-600">{source.maskedKey}</span>
</div>
) : (
<p className="text-2xs text-ink-400"> API Key</p>
)}
<div className="flex items-center justify-between text-xs">
<span className="text-ink-400"></span>
<span className="text-ink-600">{source.lastIngestion || '暂无记录'}</span>
</div>
</div>
{rowNotice && cardKeys.includes(rowNotice.key) && (
<Alert kind={rowNotice.ok ? 'ok' : 'error'} title={rowNotice.text} />
)}
{/* 数据源健康:最近采集 + 异常提示 */}
{renderHealth(source.name)}
{result && testingSource !== source.name && (
{/* 测试结果 */}
{result && (
<Alert
kind={result.ok ? 'ok' : 'error'}
title={result.ok ? `连接成功(${result.latency_ms}ms)` : result.status ? `HTTP ${result.status}` : '连接失败'}
message={result.ok ? undefined : result.detail}
kind={result.success ? 'ok' : 'error'}
title={result.success ? '连接成功' : '连接失败'}
message={result.success ? undefined : result.message}
/>
)}
{/* 操作按钮 */}
<button
onClick={() => handleTest(source.name)}
disabled={testingSource === source.name}
@@ -281,93 +120,29 @@ export default function DataSourcesPage() {
</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>
}
/>
<CardHeader title="数据源说明" />
<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 className="grid gap-x-6 gap-y-3 sm:grid-cols-3">
<div className="border-t border-ink-200 pt-3">
<Badge status="info">Bzzoiro</Badge>
<p className="mt-2 text-xs leading-relaxed text-ink-600">
, API Key
</p>
</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 && (
<Card>
<CardHeader title="近期预测活动" description="过去 24 小时 / 7 天的预测次数" />
<CardBody>
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">{stats.predictions.last_24h}</div>
<div className="mt-1 text-2xs text-ink-400"> 24 </div>
</div>
<div>
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">{stats.predictions.last_7d}</div>
<div className="mt-1 text-2xs text-ink-400"> 7 </div>
</div>
<div>
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">{stats.predictions.total}</div>
<div className="mt-1 text-2xs text-ink-400"></div>
</div>
<div className="border-t border-ink-200 pt-3">
<Badge status="info">Understat</Badge>
<p className="mt-2 text-xs leading-relaxed text-ink-600">
xG(), API Key,
</p>
</div>
<div className="border-t border-ink-200 pt-3">
<Badge status="info">Injuries</Badge>
<p className="mt-2 text-xs leading-relaxed text-ink-600">
, API Key
</p>
</div>
</CardBody>
</Card>
)}
<Card>
<CardHeader title="配置说明" />
<CardBody>
<div className="space-y-3 text-xs leading-relaxed text-ink-600">
<p className="border-l-2 border-ink-300 pl-3">
<code className="font-mono">app_settings</code> <b></b>, <code className="font-mono">.env</code>; .env
</p>
<p className="border-l-2 border-ink-300 pl-3">
(); Key / /
</p>
<p className="border-l-2 border-ink-300 pl-3">
;,
</p>
</div>
</CardBody>
</Card>
-212
View File
@@ -1,212 +0,0 @@
/**
* Admin 后台 - 评估汇总页(报刊风)
*
* 功能:
* - 按 provider / model / prompt_version / mode / league_code 筛选
* - 展示汇总统计卡片(已结算/已评估/跳过数)
* - 准确率对比表格
* - 空态与加载态
*/
import { useCallback, useEffect, useState } from 'react'
import { fetchEvalSummary, fetchLeagues } from '../dal'
import type { EvalSummary } from '../types'
import { Card, CardBody, CardHeader, StatCard, Badge, DataTable, Alert, Spinner, EmptyText } from '../components'
interface Filters {
provider: string
model: string
prompt_version: string
mode: string
league_code: string
}
const EMPTY_FILTERS: Filters = { provider: '', model: '', prompt_version: '', mode: '', league_code: '' }
export default function EvalPage() {
const [filters, setFilters] = useState<Filters>(EMPTY_FILTERS)
const [leagues, setLeagues] = useState<Array<{ code: string; name: string }>>([])
const [data, setData] = useState<EvalSummary | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
fetchLeagues()
.then(l => setLeagues(l.map(x => ({ code: x.code, name: x.name }))))
.catch(() => setLeagues([]))
}, [])
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const params: Record<string, string> = {}
if (filters.provider) params.provider = filters.provider
if (filters.model) params.model = filters.model
if (filters.prompt_version) params.prompt_version = filters.prompt_version
if (filters.mode) params.mode = filters.mode
if (filters.league_code) params.league_code = filters.league_code
const result = await fetchEvalSummary(params)
setData(result)
} catch (err: unknown) {
setError(err instanceof Error ? err.message : '加载失败')
} finally {
setLoading(false)
}
}, [filters])
useEffect(() => { load() }, [load])
const handleChange = (key: keyof Filters) => (e: React.ChangeEvent<HTMLSelectElement | HTMLInputElement>) => {
setFilters(f => ({ ...f, [key]: e.target.value }))
}
const handleReset = () => setFilters(EMPTY_FILTERS)
const summary = data?.summary ?? []
return (
<div className="space-y-6">
{/* 筛选控件 */}
<Card>
<CardHeader title="筛选条件" description="按提供商 / 模型 / 版本 / 模式 / 联赛过滤评估数据" />
<CardBody>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5">
<label className="block">
<span className="text-2xs text-ink-500"></span>
<input
type="text"
value={filters.provider}
onChange={handleChange('provider')}
placeholder="如 openai"
className="mt-1 w-full border border-ink-300 bg-paper-50 px-2.5 py-1.5 text-sm text-ink-900 placeholder:text-ink-300 focus:border-ink-900 focus:outline-none"
/>
</label>
<label className="block">
<span className="text-2xs text-ink-500"></span>
<input
type="text"
value={filters.model}
onChange={handleChange('model')}
placeholder="如 gpt-4o"
className="mt-1 w-full border border-ink-300 bg-paper-50 px-2.5 py-1.5 text-sm text-ink-900 placeholder:text-ink-300 focus:border-ink-900 focus:outline-none"
/>
</label>
<label className="block">
<span className="text-2xs text-ink-500">Prompt </span>
<input
type="text"
value={filters.prompt_version}
onChange={handleChange('prompt_version')}
placeholder="如 v1"
className="mt-1 w-full border border-ink-300 bg-paper-50 px-2.5 py-1.5 text-sm text-ink-900 placeholder:text-ink-300 focus:border-ink-900 focus:outline-none"
/>
</label>
<label className="block">
<span className="text-2xs text-ink-500"></span>
<select
value={filters.mode}
onChange={handleChange('mode')}
className="mt-1 w-full border border-ink-300 bg-paper-50 px-2.5 py-1.5 text-sm text-ink-900 focus:border-ink-900 focus:outline-none"
>
<option value=""></option>
<option value="single">single</option>
<option value="multi">multi</option>
</select>
</label>
<label className="block">
<span className="text-2xs text-ink-500"></span>
<select
value={filters.league_code}
onChange={handleChange('league_code')}
className="mt-1 w-full border border-ink-300 bg-paper-50 px-2.5 py-1.5 text-sm text-ink-900 focus:border-ink-900 focus:outline-none"
>
<option value=""></option>
{leagues.map(l => (
<option key={l.code} value={l.code}>{l.name ?? l.code}</option>
))}
</select>
</label>
</div>
<div className="mt-3 flex gap-2">
<button onClick={load} disabled={loading} className="btn btn-sm">
{loading ? '加载中…' : '应用筛选'}
</button>
<button onClick={handleReset} disabled={loading} className="btn btn-sm btn-ghost">
</button>
</div>
</CardBody>
</Card>
{/* 错误态 */}
{error && <Alert kind="error" title="加载失败" message={error} />}
{/* 汇总统计 */}
{data && (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<StatCard label="已结算总数" value={data.total_settled} hint="含 degraded" />
<StatCard label="筛选后已结算" value={data.filtered_settled} hint="应用筛选条件后" />
<StatCard label="实际评估" value={data.evaluated} hint="status=success 且比分齐全" />
<StatCard label="跳过 degraded" value={data.skipped_degraded} hint="不计入准确率" />
</div>
)}
{/* 准确率表格 */}
<Card>
<CardHeader
title="准确率对比"
description={!loading && summary.length > 0 ? `${summary.length}` : "按 provider × 模型 × prompt_version 聚合,仅统计有效预测"}
/>
<CardBody>
{loading ? (
<div className="flex items-center justify-center py-12">
<Spinner />
</div>
) : summary.length === 0 ? (
<EmptyText text="暂无评估数据,请调整筛选条件或先完成预测与结算" />
) : (
<div className="overflow-x-auto">
<DataTable
columns={[
{ key: 'provider', label: '提供商' },
{ key: 'model', label: '模型' },
{ key: 'prompt_version', label: '版本', render: (row: any) => (
<span className="font-mono text-2xs">{row.prompt_version ?? '—'}</span>
) },
{ key: 'total', label: '评估条数' },
{ key: 'accuracy_1x2', label: '1X2 准确率', render: (row: any) => (
<span className="tabular-nums">{row.accuracy_1x2 != null ? `${row.accuracy_1x2}%` : '—'}</span>
) },
{ key: 'avg_score_rmse', label: '比分 RMSE', render: (row: any) => (
<span className="tabular-nums">{row.avg_score_rmse != null ? row.avg_score_rmse.toFixed(2) : '—'}</span>
) },
{ key: 'avg_subjective_confidence', label: '平均置信度', render: (row: any) => (
<span className="tabular-nums">{row.avg_subjective_confidence != null ? row.avg_subjective_confidence.toFixed(2) : '—'}</span>
) },
{ key: 'calibration', label: '置信度校准(桶命中率)', render: (row: any) => (
row.calibration ? (
<div className="flex flex-wrap gap-x-3 gap-y-1 text-2xs">
{Object.entries(row.calibration).map(([name, b]: [string, any]) => (
<span key={name} className="inline-flex items-center gap-1">
<span className="text-ink-400">{name}:</span>
<span className="tabular-nums font-medium">
{b.hit_rate != null ? `${b.hit_rate}%` : '—'}
</span>
<span className="text-ink-300">({b.total})</span>
</span>
))}
</div>
) : '—'
) },
]}
data={summary}
rowKey={(row: any) => `${row.provider}-${row.model}-${row.prompt_version ?? ''}`}
emptyText="暂无评估数据"
/>
</div>
)}
</CardBody>
</Card>
</div>
)
}
+68 -111
View File
@@ -9,14 +9,17 @@
*/
import { useEffect, useState, useCallback } from 'react'
import { testLLMConnection, fetchLLMUsageStats, fetchSettings, fetchLLMModels, updateSetting, clearSetting } from '../dal'
import { testLLMConnection, fetchLLMUsageStats } from '../dal'
import type { LLMUsageStats } from '../types'
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components'
import SettingRow from '../SettingRow'
import AgentLLMCard from '../AgentLLMCard'
import type { DataSourceSetting } from '../types'
const LLM_SETTING_KEYS = ['LLM_API_KEY', 'LLM_BASE_URL', 'LLM_MODEL']
// 可用模型列表
const AVAILABLE_MODELS = [
{ id: 'gpt-4o', label: 'GPT-4o', provider: 'openai', description: '综合能力最强,适合复杂分析' },
{ id: 'gpt-4o-mini', label: 'GPT-4o Mini', provider: 'openai', description: '快速经济,适合批量预测' },
{ id: 'claude-3-5-sonnet', label: 'Claude 3.5 Sonnet', provider: 'anthropic', description: '长上下文分析能力强' },
{ id: 'deepseek-chat', label: 'DeepSeek V3', provider: 'deepseek', description: '高性价比,中文优化' },
]
export default function LLMConfigPage() {
const [stats, setStats] = useState<LLMUsageStats | null>(null)
@@ -24,12 +27,14 @@ export default function LLMConfigPage() {
const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null)
// LLM 连接配置(运行时配置,DB 覆盖 .env)
const [llmSettings, setLlmSettings] = useState<DataSourceSetting[]>([])
const [settingsLoading, setSettingsLoading] = useState(true)
const [editingKey, setEditingKey] = useState<string | null>(null)
const [busyKey, setBusyKey] = useState<string | null>(null)
const [rowNotice, setRowNotice] = useState<{ key: string; ok: boolean; text: string } | null>(null)
// 当前配置(后端暂无配置端点,取 .env 约定值展示)
const currentConfig = {
provider: 'openai',
model: 'gpt-4o',
base_url: 'https://api.openai.com/v1',
api_key_configured: true,
api_key_masked: 'sk-****...****abcd',
}
const loadStats = useCallback(async () => {
setLoading(true)
@@ -43,58 +48,7 @@ export default function LLMConfigPage() {
}
}, [])
const loadSettings = useCallback(async () => {
setSettingsLoading(true)
try {
const all = await fetchSettings()
setLlmSettings(all.filter(x => LLM_SETTING_KEYS.includes(x.key)))
} catch {
setLlmSettings([])
} finally {
setSettingsLoading(false)
}
}, [])
useEffect(() => {
loadStats()
loadSettings()
}, [loadStats, loadSettings])
/** 供 LLM_MODEL 行内检测:探测当前服务可用模型,失败抛错由行内展示 */
const detectLLMModels = useCallback(async (): Promise<string[]> => {
const r = await fetchLLMModels()
if (!r.ok) throw new Error(r.detail)
return r.models
}, [])
async function handleSave(key: string, value: string) {
setBusyKey(key)
setRowNotice(null)
try {
await updateSetting(key, value)
setRowNotice({ key, ok: true, text: '已保存,立即生效' })
setEditingKey(null)
await loadSettings()
} catch (err) {
setRowNotice({ key, ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '保存失败' })
} finally {
setBusyKey(null)
}
}
async function handleClear(key: string) {
setBusyKey(key)
setRowNotice(null)
try {
await clearSetting(key)
setRowNotice({ key, ok: true, text: '已清除数据库覆盖,回落 .env 默认值' })
await loadSettings()
} catch (err) {
setRowNotice({ key, ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '清除失败' })
} finally {
setBusyKey(null)
}
}
useEffect(() => { loadStats() }, [loadStats])
async function handleTest() {
setTesting(true)
@@ -118,53 +72,29 @@ export default function LLMConfigPage() {
/>
<div className="grid gap-6 lg:grid-cols-2">
{/* LLM 连接配置 */}
{/* 当前配置 */}
<Card>
<CardHeader
title="连接配置"
description="保存到数据库并立即生效,优先于服务器 .env"
action={
<button onClick={loadSettings} disabled={settingsLoading} className="btn btn-sm">
{settingsLoading ? (<><Spinner /> </>) : '刷新'}
</button>
}
/>
<CardHeader title="当前配置" />
<CardBody>
{settingsLoading ? (
<div className="space-y-3">
{[1, 2, 3].map(i => <SkeletonBlock key={i} className="h-9 w-full" />)}
</div>
) : (
<div>
{llmSettings.map(setting => (
<SettingRow
key={setting.key}
setting={setting}
editing={editingKey === setting.key}
busy={busyKey === setting.key}
onEdit={() => {
setEditingKey(setting.key)
setRowNotice(null)
}}
onCancel={() => setEditingKey(null)}
onSave={v => handleSave(setting.key, v)}
onClear={() => handleClear(setting.key)}
detectModels={setting.key === 'LLM_MODEL' ? detectLLMModels : undefined}
/>
))}
</div>
)}
{rowNotice && (
<div className="mt-3">
<Alert kind={rowNotice.ok ? 'ok' : 'error'} title={rowNotice.text} />
</div>
)}
<p className="mt-3 border-l-2 border-ink-300 pl-3 text-2xs leading-relaxed text-ink-500">
模式: 多专家 (5 + ) OpenAI ( DeepSeek
) LLM
</p>
<div className="space-y-0">
{[
{ label: '提供商', value: currentConfig.provider, mono: false },
{ label: '模型', value: currentConfig.model, mono: true },
{ label: 'API 地址', value: currentConfig.base_url, mono: true },
{ label: 'API Key', value: currentConfig.api_key_masked, mono: true },
{ label: '模式', value: '多专家 (5 路 + 终裁)', mono: false },
].map(row => (
<div
key={row.label}
className="flex items-center justify-between gap-4 border-b border-ink-200 py-2.5 last:border-b-0"
>
<span className="text-xs text-ink-400">{row.label}</span>
<span className={`text-xs text-ink-800 ${row.mono ? 'break-all font-mono' : ''}`}>
{row.value}
</span>
</div>
))}
</div>
{/* 测试连接 */}
{testResult && (
@@ -181,7 +111,7 @@ export default function LLMConfigPage() {
{testing ? (<><Spinner /> </>) : '测试 LLM 连接'}
</button>
<p className="mt-2 text-center text-2xs text-ink-400">
LLM ,
, LLM
</p>
</CardBody>
</Card>
@@ -231,8 +161,35 @@ export default function LLMConfigPage() {
</Card>
</div>
{/* 专家与终裁独立配置 */}
<AgentLLMCard />
{/* 可用模型 */}
<Card>
<CardHeader title="可用模型" description="在 .env 中修改 LLM_MODEL 后重启服务生效" />
<CardBody className="px-0 sm:px-0">
{AVAILABLE_MODELS.map(model => {
const isCurrent = model.id === currentConfig.model
return (
<div
key={model.id}
className={`flex flex-col gap-2 border-b border-ink-200 px-4 py-3 last:border-b-0 sm:flex-row sm:items-center sm:justify-between sm:px-5 ${
isCurrent ? 'bg-press-wash/50' : ''
}`}
>
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-ink-900">{model.label}</span>
{isCurrent && <Badge status="success"></Badge>}
</div>
<p className="mt-0.5 text-2xs text-ink-500">{model.description}</p>
</div>
<div className="flex items-center gap-3">
<span className="font-mono text-2xs text-ink-400">{model.provider}</span>
{!isCurrent && <span className="text-2xs text-ink-400"> .env </span>}
</div>
</div>
)
})}
</CardBody>
</Card>
{/* 最近预测 */}
<Card>
-171
View File
@@ -1,171 +0,0 @@
/**
* Admin 后台 - 系统日志页面(报刊风)
*
* 查看应用运行日志(内存缓冲,最新在前):
* - 级别筛选 + 关键字搜索
* - 自动刷新(10s)可开关
* - 缓冲上限 2000 条,进程重启后清零
*/
import { useEffect, useState, useCallback, useRef } from 'react'
import { fetchLogs } from '../dal'
import type { LogEntry } from '../types'
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components'
const LEVELS = ['', 'INFO', 'WARNING', 'ERROR'] as const
const LEVEL_BADGE: Record<string, { status: 'success' | 'info' | 'warning' | 'error'; text: string }> = {
DEBUG: { status: 'info', text: 'DEBUG' },
INFO: { status: 'info', text: 'INFO' },
WARNING: { status: 'warning', text: 'WARN' },
ERROR: { status: 'error', text: 'ERROR' },
CRITICAL: { status: 'error', text: 'FATAL' },
}
function fmtTs(ts: number): string {
return new Date(ts * 1000).toLocaleString('zh-CN', { hour12: false })
}
export default function LogsPage() {
const [entries, setEntries] = useState<LogEntry[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [level, setLevel] = useState<string>('')
const [keyword, setKeyword] = useState('')
const [autoRefresh, setAutoRefresh] = useState(true)
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const load = useCallback(async () => {
try {
const d = await fetchLogs({ level: level || undefined, keyword: keyword || undefined, limit: 300 })
setEntries(d.entries)
setError('')
} catch (err) {
setError(err instanceof Error ? err.message.split('\n')[0] : '加载失败')
} finally {
setLoading(false)
}
}, [level, keyword])
// 筛选条件变化 → 立即拉取
useEffect(() => {
load()
}, [load])
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(() => {
if (timerRef.current) clearInterval(timerRef.current)
if (autoRefresh) {
timerRef.current = setInterval(load, 10_000)
}
return () => {
if (timerRef.current) clearInterval(timerRef.current)
}
}, [autoRefresh, load])
return (
<div className="space-y-6">
<SectionHeader
title="系统日志"
description="应用运行日志(登录、配置变更、采集、预测、异常等)。内存缓冲最近 2000 条,重启后清零。"
/>
{error && <Alert kind="error" title="无法加载日志" message={error} />}
<Card>
<CardHeader
title="日志查看"
description={entries.length > 0 ? `显示最新 ${entries.length}` : undefined}
action={
<div className="flex items-center gap-2">
<label className="flex cursor-pointer items-center gap-1.5 text-2xs text-ink-500">
<input
type="checkbox"
checked={autoRefresh}
onChange={e => setAutoRefresh(e.target.checked)}
className="accent-current"
/>
10s
</label>
<button onClick={load} disabled={loading} className="btn btn-sm">
{loading ? (<><Spinner /> </>) : '刷新'}
</button>
</div>
}
/>
<CardBody className="px-0 sm:px-0">
{/* 筛选栏 */}
<div className="flex flex-col gap-2 border-b border-ink-200 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-5">
<div className="flex flex-wrap gap-1.5">
{LEVELS.map(lv => (
<button
key={lv || 'all'}
onClick={() => setLevel(lv)}
className={`btn btn-sm ${level === lv ? 'btn-solid' : ''}`}
>
{lv || '全部'}
</button>
))}
</div>
<input
value={keyword}
onChange={e => setKeyword(e.target.value)}
placeholder="搜索关键字(消息 / logger)…"
className="field w-full sm:w-64"
/>
</div>
{/* 日志列表 */}
{loading && entries.length === 0 ? (
<div className="space-y-2 px-4 py-3 sm:px-5">
{[1, 2, 3, 4, 5].map(i => <SkeletonBlock key={i} className="h-8 w-full" />)}
</div>
) : entries.length === 0 ? (
<p className="py-10 text-center text-xs text-ink-400"></p>
) : (
<div ref={scrollRef} onScroll={handleScroll} className="max-h-[60vh] overflow-y-auto">
{entries.map((e, i) => {
const badge = LEVEL_BADGE[e.level] ?? { status: 'info' as const, text: e.level }
return (
<div
key={`${e.ts}-${i}`}
className="flex flex-col gap-0.5 border-b border-ink-200 px-4 py-2 last:border-b-0 sm:flex-row sm:items-baseline sm:gap-3 sm:px-5"
>
<span className="w-40 flex-shrink-0 text-2xs tabular-nums text-ink-400">{fmtTs(e.ts)}</span>
<span className="w-14 flex-shrink-0">
<Badge status={badge.status}>{badge.text}</Badge>
</span>
<span className="w-40 flex-shrink-0 truncate font-mono text-2xs text-ink-400" title={e.logger}>
{e.logger}
</span>
<span className="min-w-0 flex-1 break-all font-mono text-2xs leading-relaxed text-ink-800">
{e.message}
</span>
</div>
)
})}
</div>
)}
</CardBody>
</Card>
</div>
)
}
+1 -1
View File
@@ -66,7 +66,7 @@ export default function MonitoringPage() {
<Alert
kind="error"
title="无法连接到后端"
message={`${error}\n请确认服务是否正常运行,以及登录会话是否已过期`}
message={`${error}\n请确认服务是否正常运行,以及管理员密钥是否需要配置`}
/>
)}
@@ -1,316 +0,0 @@
/**
* 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>
)
}
+9 -21
View File
@@ -13,15 +13,13 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { triggerPrediction, fetchPredictions, fetchMatches, settlePrediction } from '../dal'
import type { Match, Prediction } from '../types'
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
import { teamSidePrefix } from '../../components/TeamSideTag'
import { AgentWeightsBar } from '../components'
const AGENT_LABELS: Record<string, string> = {
h2h: '历史交锋分析专家',
form: '近期状态分析专家',
stats: '攻防数据分析专家',
home_away: '主客因素分析专家',
injuries: '阵容完整性分析专家',
h2h: '历史交锋',
form: '近期状态',
stats: '攻防数据',
home_away: '主客因素',
injuries: '阵容完整性',
}
const OUTCOME_LABEL: Record<string, string> = { '1': '主胜', X: '平局', '2': '客胜' }
@@ -66,7 +64,7 @@ export default function PredictionsPage() {
for (const m of matches) {
const home = m.home_team_zh || m.home_team
const away = m.away_team_zh || m.away_team
map.set(m.id, `${teamSidePrefix('home')}${home} vs ${teamSidePrefix('away')}${away}`)
map.set(m.id, `${home} vs ${away}`)
}
return map
}, [matches])
@@ -142,7 +140,7 @@ export default function PredictionsPage() {
<option value=""></option>
{matches.map(m => (
<option key={m.id} value={m.id}>
{teamSidePrefix('home')}{(m.home_team_zh || m.home_team)} vs {teamSidePrefix('away')}{(m.away_team_zh || m.away_team)}
{(m.home_team_zh || m.home_team)} vs {(m.away_team_zh || m.away_team)}
({m.match_date?.slice(5, 10)})
</option>
))}
@@ -291,14 +289,8 @@ export default function PredictionsPage() {
<Badge status="success"></Badge>
) : (
<Badge status="pending"></Badge>
)}
{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>
)}
<span className="text-2xs tabular-nums text-ink-400">{fmtTime(p.created_at)}</span>
<svg
viewBox="0 0 20 20"
className="h-3 w-3 self-center text-ink-300 transition-transform group-open:rotate-90"
@@ -324,10 +316,6 @@ export default function PredictionsPage() {
</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 && (
<ul className="space-y-1">
{p.agent_outputs.map((a, i) => (
-496
View File
@@ -1,496 +0,0 @@
/**
* 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>
)
}
+7 -11
View File
@@ -9,14 +9,12 @@ import { Navigate } from 'react-router-dom'
import AdminLayout from './AdminLayout'
import Dashboard from './pages/Dashboard'
import CollectionPage from './pages/Collection'
import DataCompletenessPage from './pages/DataCompleteness'
import PredictionsPage from './pages/PredictionHistory'
import PredictionsPage from './pages/Predictions'
import BacktestPage from './pages/Backtest'
import MonitoringPage from './pages/Monitoring'
import SettingsPage from './pages/Settings'
import LogsPage from './pages/Logs'
import EvalPage from './pages/EvalPage'
import DataPipelinePage from './pages/DataPipeline'
import DataSourcesPage from './pages/DataSources'
import LLMConfigPage from './pages/LLMConfig'
import ConfigPage from './pages/Config'
export const adminRoutes = [
{
@@ -25,14 +23,12 @@ export const adminRoutes = [
children: [
{ index: true, element: <Dashboard /> },
{ path: 'collection', element: <CollectionPage /> },
{ path: 'data-completeness', element: <DataCompletenessPage /> },
{ path: 'data-pipeline', element: <DataPipelinePage /> },
{ path: 'predictions', element: <PredictionsPage /> },
{ path: 'backtest', element: <BacktestPage /> },
{ path: 'monitoring', element: <MonitoringPage /> },
{ path: 'settings', element: <SettingsPage /> },
{ path: 'logs', element: <LogsPage /> },
{ path: 'eval', element: <EvalPage /> },
{ path: 'data-sources', element: <DataSourcesPage /> },
{ path: 'llm-config', element: <LLMConfigPage /> },
{ path: 'config', element: <ConfigPage /> },
{ path: '*', element: <Navigate to="/admin" replace /> },
],
},
+24 -204
View File
@@ -17,8 +17,7 @@ export interface HealthStatus {
export interface DashboardStats {
leagues: League[]
// 比赛总量已移除: 用 fetchAdminStats()(/admin/stats)的精确 COUNT,
// 不要再用列表 items.length 近似(上限 100 会严重失真)
total_matches: number
total_predictions: number
health: string
db_tables: { name: string; row_count: number; size_mb: number; last_updated: string | null }[]
@@ -75,8 +74,6 @@ export interface Prediction {
subjective_confidence?: number | null
reasoning?: string | null
agent_outputs?: PredictionAgentOutput[] | null
agent_weights?: Record<string, number> | null
status?: 'success' | 'failed' | 'degraded'
created_at: string
actual_home_goals?: number | null
actual_away_goals?: number | null
@@ -93,11 +90,9 @@ export interface PredictRequest {
// ── 数据采集 ────────────────────────────────────────────────────
export interface CollectionRequest {
status?: string
source: 'bzzoiro'
source: 'bzzoiro' | 'understat' | 'injuries'
leagues?: string[]
task?: 'events' | 'standings' | 'stats' | 'all'
limit?: number
league?: string
season?: string
date_from?: string
date_to?: string
@@ -105,37 +100,16 @@ export interface CollectionRequest {
// ── 评估 & 回测 ─────────────────────────────────────────────────
export interface EvalCalibrationBucket {
total: number
/** 该桶命中率,百分数;样本不足为 null */
hit_rate: number | null
}
export interface EvalSummaryRow {
provider: string
model: string
prompt_version: string | null
total: number
/** 1X2 准确率,百分数 0-100 */
accuracy_1x2?: number
avg_score_rmse?: number | null
avg_subjective_confidence?: number | null
/** 置信度校准:按主观置信度分桶的命中率 */
calibration?: Record<string, EvalCalibrationBucket>
}
export interface EvalSummary {
summary: Array<EvalSummaryRow>
/** 全量已结算数 */
total_settled: number
/** 应用筛选后的已结算数 */
filtered_settled: number
/** 实际评估条数(status=success 且比分齐全) */
evaluated: number
/** 跳过的 degraded 条数 */
skipped_degraded: number
/** 跳过的比分不全条数 */
skipped_incomplete?: number
summary: Array<{
provider: string
model: string
total: number
/** 1X2 准确率,百分数 0-100 */
accuracy_1x2?: number
avg_score_rmse?: number | null
avg_subjective_confidence?: number | null
}>
}
export interface BacktestRequest {
@@ -150,39 +124,27 @@ export interface BacktestRequest {
export interface BacktestSummary {
total: number
scored: number
success: number
degraded: number
accuracy_1x2?: number
avg_score_rmse?: number
avg_subjective_confidence?: number
results?: Array<{
match_id: number
actual_home: number
actual_away: number
pred_home?: number
pred_away?: number
correct_1x2: boolean
}>
}
// ── 数据源配置 ──────────────────────────────────────────────────
export interface DataSourceSetting {
key: string
label: string
description: string
sensitive: boolean
configured: boolean
masked: string
origin: 'db' | 'env' | 'none'
}
export interface DataSourceStatus {
name: string
label: string
description: string
key_configured: boolean
last_ingestion: string | null
settings: DataSourceSetting[]
}
export interface DataSourceTestResult {
ok: boolean
status: number | null
latency_ms: number
detail: string
keyConfigured: boolean
maskedKey: string
lastIngestion: string | null
status: 'configured' | 'missing_key' | 'untested'
}
export interface DataSourceTestRequest {
@@ -231,145 +193,3 @@ export interface SystemConfigEntry {
description: string
is_sensitive: boolean
}
// ── 专家/终裁独立 LLM 配置 ────────────────────────────────────────
export interface LLMAgentFieldState {
configured: boolean
masked: string
origin: 'db' | 'env' | 'none'
}
export interface LLMAgentConfig {
id: string
label: string
effective_model: string
fields: {
model: LLMAgentFieldState
base_url: LLMAgentFieldState
api_key: LLMAgentFieldState
}
}
// ── 系统日志 ─────────────────────────────────────────────────────
export interface LogEntry {
ts: number
level: string
logger: string
message: string
}
// ── 数据源健康/最近采集状态 ─────────────────────────────────────
export interface IngestLastFailure {
at: string
logger: string
detail: string
note: string
}
export interface IngestSourceStatus {
name: string
label: string
key_configured: boolean
base_url?: string
reachable: boolean | null
status?: 'key_not_configured' | 'no_data' | 'has_data'
last_success_at: string | null
latest_match_date?: string | null
recent_count: number
note: string
last_failure: IngestLastFailure | null
}
// ── 比赛详情 ─────────────────────────────────────────────────────
export interface MatchRecentPrediction {
id: number
provider: string
model: string
mode: string
pred_home_goals: number | null
pred_away_goals: number | null
alt_pred_home_goals: number | null
alt_pred_away_goals: number | null
pred_1x2: string | null
subjective_confidence: number | null
reasoning: string | null
status: string
settled: boolean
correct_1x2?: boolean
created_at: string
actual_home_goals: number | null
actual_away_goals: number | null
agent_outputs?: Array<Record<string, any>> | null
agent_weights?: Record<string, number> | null
}
export interface MatchDetailOut {
id: number
league_code: string | null
season: string | null
home_team: string
away_team: string
home_team_zh: string | null
away_team_zh: string | null
match_date: string
match_status: string
home_goals: number | null
away_goals: number | null
match_stage: string | null
home_xg: number | null
away_xg: number | null
stats: MatchStatsDetail | null
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 {
match_date: string | null
home_team: string | null
away_team: string | null
home_goals: number | null
away_goals: number | null
}
export interface MatchContextOut {
home_recent: TeamRecentMatch[]
away_recent: TeamRecentMatch[]
h2h: TeamRecentMatch[]
}
// ── 管理区统计 ─────────────────────────────────────────────────
export interface AdminStats {
predictions: {
total: number
last_24h: number
last_7d: number
}
matches?: { total: number; finished: number }
stats?: { total: number }
standings?: { total: number }
}
-155
View File
@@ -1,155 +0,0 @@
/**
* 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>
)
}
-26
View File
@@ -1,26 +0,0 @@
/**
* 主客队标志:报刊风小方框字。
*
* 主队 = 反白实心块,客队 = 细线框,与整版纸色语言一致。
* 用法: <TeamSideTag side="home" /> 队名
*/
export default function TeamSideTag({ side }: { side: 'home' | 'away' }) {
return (
<span
aria-label={side === 'home' ? '主队' : '客队'}
className={`inline-block flex-shrink-0 border px-1 text-center font-sans text-2xs leading-4 ${
side === 'home'
? 'border-ink-900 bg-ink-900 text-paper-50'
: 'border-ink-300 text-ink-400'
}`}
>
{side === 'home' ? '主' : '客'}
</span>
)
}
/** 纯文本场景(<option>、字符串拼接)的主客队前缀 */
export function teamSidePrefix(side: 'home' | 'away'): string {
return side === 'home' ? '[主] ' : '[客] '
}
+74 -88
View File
@@ -2,36 +2,13 @@
@tailwind components;
@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 {
/* 原生 CSS 里引用的令牌:与 tailwind.config 保持同步,避免硬编码色值散落 */
:root {
--c-press: #9E1B1B;
--c-ink-900: #17140F;
}
html {
-webkit-text-size-adjust: 100%;
text-rendering: optimizeLegibility;
@@ -48,7 +25,7 @@
/* 统一焦点环:印报红细线,键盘可达 */
:focus-visible {
outline: 2px solid #9e1b1b;
outline: 2px solid var(--c-press);
outline-offset: 2px;
}
@@ -80,72 +57,97 @@
}
@layer components {
/* ── 报头双线:粗线在上、细线在下 ── */
/* ── 报头 Oxford 粗细双线:3px 粗线 + 2px 空 + 1px 细线 ── */
.masthead-rule {
border-top: 3px solid #17140f;
border-top: 3px solid var(--c-ink-900);
position: relative;
}
.masthead-rule::before {
content: '';
position: absolute;
top: 2px;
left: 0;
right: 0;
height: 1px;
background: var(--c-ink-900);
}
/* ── 按钮:方正边框式,悬停反白 ── */
/* ── 刊名斜体专用:Georgia 提供真斜体(其 old-style 数字不适合比分,已从主栈让位) ── */
.wordmark {
font-family: Georgia, 'Times New Roman', serif;
}
/* ── 纵排文字:预测比分两侧的队名,中文报纸原生版面语言 ── */
.v-name {
writing-mode: vertical-rl;
letter-spacing: 0.3em;
}
/* ── 纸张噪点:feTurbulence 颗粒,给纸面一点纤维感 ──
加在页面根元素上,fixed 全屏覆盖,不响应指针 */
.grain::after {
content: '';
position: fixed;
inset: 0;
z-index: 60;
pointer-events: none;
opacity: 0.04;
mix-blend-mode: multiply;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.8'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
/* ── 入场:整页一次编排好的升起序列 ──
只动 opacity/transform;错峰由元素上的 animation-delay 内联指定。
隐藏态必须与动画同在一个 no-preference 块里,否则 reduce 用户会看到永久空白 */
@media (prefers-reduced-motion: no-preference) {
.rise-in {
opacity: 0;
animation: rise-in 0.48s cubic-bezier(0.16, 1, 0.3, 1) both;
}
}
@keyframes rise-in {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: none;
}
}
/* ── 按钮:方正边框式,悬停反白;热区压到 40px(紧凑报纸行距下的折中) ── */
.btn {
@apply inline-flex items-center justify-center gap-1.5 border border-ink-300 bg-transparent px-3
@apply inline-flex min-h-[40px] items-center justify-center gap-1.5 border border-ink-300 bg-transparent px-3 py-1.5
text-sm text-ink-700 transition-colors duration-150
hover:border-ink-900 hover:bg-ink-900 hover:text-paper-50
disabled:cursor-not-allowed disabled:opacity-40
disabled:hover:border-ink-300 disabled:hover:bg-transparent disabled:hover:text-ink-700
min-h-[44px] py-1.5;
disabled:hover:border-ink-300 disabled:hover:bg-transparent disabled:hover:text-ink-700;
}
.btn-sm {
@apply px-2.5 text-xs min-h-[36px];
@apply min-h-[32px] px-2.5 py-1 text-xs;
}
.btn-solid {
@apply border-ink-900 bg-ink-900 text-paper-50 hover:border-press hover:bg-press;
}
.btn-danger {
@apply border-press bg-transparent text-press hover:bg-press hover:text-paper-50;
}
/* ── 统一空态 ── */
.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 {
@apply border border-ink-300 bg-transparent px-2.5 py-1.5 text-sm text-ink-800
transition-colors hover:border-ink-400
focus:border-press focus:outline-none;
@apply border border-ink-300 bg-transparent px-2.5 py-2 text-sm text-ink-800
transition-colors hover:border-ink-400 focus:border-press;
}
/* ── 版面切换文字标签(联赛/状态/模式) ── */
/* ── 版面切换文字标签(联赛/状态/模式) ──
激活态用墨色:印报红只留给 live/终裁/印章/错误,维持 10% 纪律 */
.tab {
@apply relative whitespace-nowrap px-0.5 py-1 text-sm text-ink-500 transition-colors hover:text-ink-900;
}
.tab-on {
@apply font-medium text-press;
@apply font-medium text-ink-900;
}
.tab-on::after {
content: '';
@apply absolute inset-x-0 bottom-0 h-0.5 bg-press;
@apply absolute inset-x-0 bottom-0 h-0.5 bg-ink-900;
}
/* ── 小节标题:宋体加粗 + 墨色底线 ── */
@@ -157,22 +159,6 @@
.skeleton {
@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 后台 ──
-103
View File
@@ -1,103 +0,0 @@
/**
* 共享 HTTP 客户端(公开站 + Admin 统一)
*
* 特性:
* - 统一超时(默认 30s,可覆盖)
* - 统一错误处理(ApiError)
* - 401 自动广播(Admin 场景)
* - JSON/HTML 容错解析
* - 请求竞态防护(可选 signal)
*
* 注: Admin 侧的 admin/api.ts 是本模块的薄门面,不再有第二套实现。
*/
/** 会话失效事件名,AdminLayout 监听后弹出登录页 */
export const UNAUTHORIZED_EVENT = 'profeto:unauthorized'
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' }),
}
+5
View File
@@ -1,6 +1,11 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
// 中文衬线 webfont:按需子集加载(fontsource 按 unicode-range 切片,只拉用到的字块),
// font-display: swap 由包内 CSS 自带,不会白屏
import '@fontsource/noto-serif-sc/400.css'
import '@fontsource/noto-serif-sc/600.css'
import '@fontsource/noto-serif-sc/700.css'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
File diff suppressed because it is too large Load Diff
-239
View File
@@ -1,239 +0,0 @@
/**
* 主站 - 联赛积分榜页
*
* 展示各联赛最新积分榜(位置/积分/净胜/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>
)
}
+5 -3
View File
@@ -12,12 +12,13 @@ export default {
300: '#DDD7C7',
},
// 墨色:暖黑灰阶,替代冷调 slate
// 400 档压在 4.5:1 线上(paper-100 底 4.55:1 / paper-50 底 4.87:1),可作正文级小字
ink: {
50: '#FAF9F7',
100: '#F0EEE9',
200: '#E2DFD7',
300: '#C9C4B8',
400: '#9C9587',
400: '#756F61',
500: '#6E675B',
600: '#524C42',
700: '#3B362E',
@@ -32,10 +33,11 @@ export default {
},
},
fontFamily: {
// 毛体草书(国内 CDN)+ 粗楷体回退
brush: ['"Liu Jian Mao Cao"', '"KaiTi"', '"STKaiti"', '"Kaiti SC"', '"楷体"', '"Songti SC"', 'serif'],
// 标题与比分:宋体血统,报纸版面的骨架
// Noto Serif SC 站首位:拉丁与数字走它的直线字形(Georgia 是 old-style 数字,
// 比分 3:1 会高低不齐);Georgia 只保留给刊名斜体(.wordmark)
serif: [
'"Noto Serif SC"',
'Georgia',
'"Times New Roman"',
'"Songti SC"',
+1 -5
View File
@@ -12,17 +12,13 @@ dependencies = [
"asyncpg>=0.29",
"psycopg2-binary>=2.9",
"httpx>=0.27",
"alembic>=1.13",
"cryptography>=42.0",
"croniter>=2.0",
]
[project.optional-dependencies]
# 开发/CI 依赖:本地安装用 pip install -e ".[dev]",CI 用 pip-sync requirements-dev.txt
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.23",
"pip-tools>=7.0",
"httpx>=0.27",
]
[build-system]
-1211
View File
File diff suppressed because it is too large Load Diff
-1184
View File
File diff suppressed because it is too large Load Diff
+3 -68
View File
@@ -10,72 +10,22 @@ from fastapi.middleware.cors import CORSMiddleware
from src.core.config import settings
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
from src.db.base import init_db
from src.core.http_client import close_client
from src.core.runtime_config import (
ensure_admin_password_hashed,
migrate_plaintext_sensitive_settings,
)
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 migrate_plaintext_sensitive_settings() # 明文敏感配置 → 加密(幂等)
await ensure_admin_password_hashed() # .env 明文密码 → scrypt 哈希(幂等)
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
await scheduler.stop()
await quality_scheduler.stop()
await close_client()
def create_app() -> FastAPI:
from src.core.log_buffer import setup_memory_logging
setup_memory_logging(settings.LOG_LEVEL)
# 生产环境不暴露 OpenAPI 文档(避免向访客泄露接口结构)
openapi_url = "/openapi.json" if settings.APP_ENV != "production" else None
app = FastAPI(
title="Profeto API",
description="足球数据 + LLM 预测服务",
version="0.1.0",
lifespan=lifespan,
openapi_url=openapi_url,
)
origins = [o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip()]
@@ -94,18 +44,12 @@ def create_app() -> FastAPI:
from src.api.routes.ingest import router as ingest_router
from src.api.routes.eval import router as eval_router
from src.api.routes.backtest import router as backtest_router
from src.api.routes.auth import router as auth_router
from src.api.routes.admin_settings import router as admin_settings_router
from src.api.routes.schedules import router as schedules_router
app.include_router(matches_router)
app.include_router(predict_router)
app.include_router(ingest_router)
app.include_router(eval_router)
app.include_router(backtest_router)
app.include_router(auth_router)
app.include_router(admin_settings_router)
app.include_router(schedules_router)
@app.get("/health")
async def health():
@@ -114,23 +58,14 @@ def create_app() -> FastAPI:
@app.get("/health/ready")
async def health_ready():
"""就绪检查:验证数据库连接。
数据库不可达时返回 HTTP 503,而非 200 + not_ready ——
这样 K8s/Compose 的 readinessProbe 才能正确判定「未就绪」并停止流量。
"""
"""就绪检查: 验证数据库连接。"""
from src.db.base import engine
from fastapi.responses import JSONResponse
try:
async with engine.begin() as conn:
await conn.run_sync(lambda conn: None)
return {"status": "ready"}
except Exception as e:
logger.warning("就绪检查失败(数据库不可达): %s", e)
return JSONResponse(
status_code=503,
content={"status": "not_ready", "reason": "database_unreachable"},
)
except Exception:
return {"status": "not_ready"}
return app
+16 -178
View File
@@ -1,199 +1,37 @@
"""API 依赖:鉴权等横切关注点。
审查报告 P2-7:ingest / backtest / settle 这类「写入型或高成本」接口此前
完全无鉴权 —— 任何能访问到服务的人都可触发采集、或直接烧掉 LLM 额度。
策略(渐进式,不破坏本地开发):
- 管理员密码:库中 scrypt 哈希优先,回落 .env 初始值;后台可在线修改
- 密码已配置(哈希或 .env)→ 管理后台可用密码登录,登录后颁发 HttpOnly
Cookie 会话;受保护接口接受 Cookie 会话或 X-API-Key
- 仅配置 `ADMIN_API_KEY` → 受保护接口只接受 `X-API-Key` 请求头(机器/脚本调用)。
- 两者都未配置 → 直接放行,并打一次 warning(本地开发模式)。
- `ADMIN_API_KEY` 未配置 → 直接放行,并打一次 warning
这样本地 `docker compose up` 无需额外配置即可用。
- 已配置 → 必须带匹配的 `X-API-Key` 请求头,否则 401
"""
from __future__ import annotations
import hmac
import logging
import secrets
import time
from fastapi import Header, HTTPException, Request
from fastapi import Header, HTTPException
from src.core.config import settings
from src.core.runtime_config import get_admin_password_hash
logger = logging.getLogger(__name__)
SESSION_COOKIE = "profeto_session"
async def require_admin_key(x_api_key: str | None = Header(None, alias="X-API-Key")) -> None:
"""保护「写入型 / 高成本」接口的依赖。
def _sign(exp_ts: int, secret: bytes) -> str:
msg = f"profeto-admin:{exp_ts}".encode()
return hmac.new(secret, msg, "sha256").hexdigest()
async def get_session_secret() -> bytes:
"""会话签名密钥 = HMAC(SECRET_KEY, 管理员凭证指纹)。
指纹来自密码哈希(密码本身永不参与签名):密码变更 → 指纹变化
→ 全部旧会话失效,无需额外吊销机制。
用法: `@router.post("/ingest/bzzoiro", dependencies=[Depends(require_admin_key)])`
"""
from src.core.runtime_config import get_admin_credential_fingerprint
fingerprint = await get_admin_credential_fingerprint()
key = settings.SECRET_KEY or f"fallback:{settings.DATABASE_URL}"
return hmac.new(key.encode(), b"session:" + fingerprint.encode(), "sha256").digest()
def create_session_token(secret: bytes) -> str:
exp_ts = int(time.time()) + settings.ADMIN_SESSION_TTL_HOURS * 3600
return f"{exp_ts}.{_sign(exp_ts, secret)}"
def verify_session_token(token: str, secret: bytes) -> bool:
try:
exp_raw, sig = token.split(".", 1)
exp_ts = int(exp_raw)
if exp_ts < int(time.time()):
return False
return secrets.compare_digest(sig, _sign(exp_ts, secret))
except (ValueError, TypeError):
return False
async def auth_configured() -> bool:
"""是否已启用鉴权(密码哈希/.env 密码/API Key 任一)。"""
return bool(
await get_admin_password_hash()
or settings.ADMIN_PASSWORD
or settings.ADMIN_API_KEY
)
async def require_admin(
request: Request,
x_api_key: str | None = Header(None, alias="X-API-Key"),
) -> None:
"""统一保护管理接口:接受 Cookie 会话(密码登录)或 X-API-Key。
用法: `@router.get("/leagues", dependencies=[Depends(require_admin)])`
"""
if not await auth_configured():
# 生产环境 fail-closed:未配置鉴权则拒绝,不放行
if settings.REQUIRE_ADMIN_AUTH or settings.APP_ENV == "production":
logger.error(
"生产环境管理接口未配置鉴权(REQUIRE_ADMIN_AUTH=True 或 APP_ENV=production),"
"拒绝访问。请设置 ADMIN_PASSWORD 或 ADMIN_API_KEY。"
)
raise HTTPException(
status_code=503,
detail="服务未配置管理鉴权,请联系管理员",
)
# 开发环境 fail-open + warning
expected = settings.ADMIN_API_KEY
if not expected:
logger.warning(
"管理员密码 / ADMIN_API_KEY 未设置,管理接口当前【无鉴权】。"
"生产环境请至少设置其中一项"
"ADMIN_API_KEY 未设置,采集/回测接口当前【无鉴权】。"
"生产环境请设置该环境变量"
)
return
# 1) Cookie 会话(密码登录颁发)
token = request.cookies.get(SESSION_COOKIE)
if token and verify_session_token(token, await get_session_secret()):
return
# 2) X-API-Key(机器/脚本调用;key 明文只在内存中,与第三方交互必需)
if (
settings.ADMIN_API_KEY
and x_api_key
and secrets.compare_digest(x_api_key, settings.ADMIN_API_KEY)
):
return
raise HTTPException(status_code=401, detail="未登录或凭证无效")
# ── 客户端 IP 提取(防 X-Forwarded-For 伪造) ──
def get_client_ip(request: Request) -> str:
"""获取客户端真实 IP,防 X-Forwarded-For 伪造。
规则:
- TRUST_PROXY_HEADERS=False(默认):只用 request.client.host,
忽略 X-Forwarded-For,防止客户端伪造。
- TRUST_PROXY_HEADERS=True:解析 X-Forwarded-For 第一个 IP,
适用于 Nginx 等可信反代后方。
部署建议:
- 公网必须设 TRUST_PROXY_HEADERS=True,并在 Nginx 配置:
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- Nginx 层也建议做限流(limit_req),作为第二道防线。
"""
if settings.TRUST_PROXY_HEADERS:
# 信任反代:X-Forwarded-For 可能包含多个 IP(代理链),取第一个
forwarded = request.headers.get("X-Forwarded-For")
if forwarded:
return forwarded.split(",")[0].strip()
# 默认或无反代头:直接用连接层 IP
return request.client.host if request.client else "unknown"
# ── 简易内存限流(按 IP,无外部依赖) ──
class _RateLimiter:
"""内存式滑动窗口限流。
设计取舍:
- 单进程内有效,多 worker 各自计数(生产前置于 Nginx 做全局限流更精确)
- 滑动窗口:记录每次请求时间戳,清理过期条目
- O(n) 清理,n = 时间窗口内请求数(通常 < 100)
"""
def __init__(self, max_requests: int = 10, window_seconds: int = 60):
self.max_requests = max_requests
self.window_seconds = window_seconds
self._hits: dict[str, list[float]] = {}
def is_allowed(self, key: str) -> bool:
"""检查 key 是否允许通过。True=允许,False=拒绝。"""
now = time.time()
window_start = now - self.window_seconds
# 获取并清理该 key 的过期记录
timestamps = self._hits.get(key, [])
timestamps = [t for t in timestamps if t > window_start]
if len(timestamps) >= self.max_requests:
self._hits[key] = timestamps # 更新清理后的列表
return False
timestamps.append(now)
self._hits[key] = timestamps
return True
def remaining(self, key: str) -> int:
"""当前窗口内剩余可用次数。"""
now = time.time()
timestamps = [t for t in self._hits.get(key, []) if t > now - self.window_seconds]
return max(0, self.max_requests - len(timestamps))
# 全局限流实例: /api/v1/predict 每分钟 10 次
_predict_limiter = _RateLimiter(max_requests=10, window_seconds=60)
async def rate_limit_predict(request: Request) -> None:
"""POST /api/v1/predict 限流依赖。
基于客户端 IP,超过 10 次/分钟返回 429。
IP 提取逻辑:优先用 get_client_ip(防伪造)。
"""
client_ip = get_client_ip(request)
if not _predict_limiter.is_allowed(client_ip):
logger.warning("rate limit exceeded for %s", client_ip)
raise HTTPException(
status_code=429,
detail="请求过于频繁,请稍后再试(每分钟最多 10 次)",
)
def get_predict_rate_limit_remaining(request: Request) -> int:
"""查询当前 IP 剩余的预测配额(用于响应中提示前端)。"""
return _predict_limiter.remaining(get_client_ip(request))
if not x_api_key or not secrets.compare_digest(x_api_key, expected):
raise HTTPException(status_code=401, detail="无效或缺失的 X-API-Key")
-668
View File
@@ -1,668 +0,0 @@
"""后台管理路由:数据源配置的查看、修改与连通性测试。
所有接口需管理员鉴权(require_admin)。配置项白名单见
src/core/runtime_config.py SETTING_DEFS,之外的 key 一律拒绝。
"""
from __future__ import annotations
import logging
import time
from datetime import date, datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import func, select
import httpx
from src.api.deps import require_admin
from src.core.config import settings
from src.core.http_client import get_client
from src.core.log_buffer import get_entries
from src.core.runtime_config import (
AGENT_META,
SETTING_DEFS,
clear_runtime_value,
get_runtime_value,
get_setting_origin,
mask_value,
set_runtime_value,
)
from src.db.base import AsyncSession, get_db_read
from src.db.models import League, Match, MatchStats, Standing
from src.data.key_ring import get_key_ring, parse_keys
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/admin", tags=["admin"], dependencies=[Depends(require_admin)])
# ── 数据源元数据(bzzoiro 单一数据源) ────────────────────────────
_SOURCES: list[dict] = [
{
"name": "bzzoiro",
"label": "Bzzoiro",
"description": "唯一数据源:赛程比分 + 积分榜 + 比赛详细统计(xG/射门/控球等)",
"setting_keys": ["BZZOIRO_KEY", "BZZOIRO_BASE"],
},
]
class SettingUpdateIn(BaseModel):
value: str
async def _last_ingestion(db: AsyncSession, source: str) -> datetime | None:
"""源最近一次采集时间(取自数据血缘字段,无记录返回 None)。"""
return (
await db.execute(
select(func.max(MatchStats.retrieved_at)).where(MatchStats.source == source)
)
).scalar()
@router.get("/datasources")
async def list_datasources(db: AsyncSession = Depends(get_db_read)):
"""数据源列表:各配置项的脱敏值、来源(db/env/none)与最近采集时间。"""
result = []
for src in _SOURCES:
settings_out = []
for key in src["setting_keys"]:
origin, value = await get_setting_origin(key)
defn = SETTING_DEFS[key]
settings_out.append(
{
"key": key,
"label": defn.label,
"description": defn.description,
"sensitive": defn.sensitive,
"configured": origin != "none",
"masked": mask_value(value, defn.sensitive),
"origin": origin,
}
)
key_configured = all(s["configured"] for s in settings_out) if settings_out else True
last = await _last_ingestion(db, src["name"])
result.append(
{
"name": src["name"],
"label": src["label"],
"description": src["description"],
"key_configured": key_configured,
"last_ingestion": last.isoformat() if last else None,
"settings": settings_out,
}
)
return result
@router.get("/settings")
async def list_settings():
"""全部可配置项(脱敏),供后台各配置页渲染。"""
out = []
for key, defn in SETTING_DEFS.items():
origin, value = await get_setting_origin(key)
out.append(
{
"key": key,
"label": defn.label,
"description": defn.description,
"sensitive": defn.sensitive,
"configured": origin != "none",
"masked": mask_value(value, defn.sensitive),
"origin": origin,
}
)
return out
# ── LLM 可用模型检测 ────────────────────────────────────────────
@router.get("/logs")
async def read_logs(
level: str | None = Query(None, description="最低级别: DEBUG/INFO/WARNING/ERROR"),
keyword: str | None = Query(None, description="消息或 logger 关键字"),
limit: int = Query(200, ge=1, le=1000),
):
"""查询应用运行日志(内存环形缓冲,最新在前;进程重启后清零)。"""
entries = get_entries(level, keyword, limit)
return {"entries": entries, "count": len(entries)}
@router.get("/llm/agents")
async def list_llm_agents():
"""各专家/终裁的独立 LLM 配置状态(含当前生效模型的解析结果)。"""
out = []
for agent in AGENT_META:
aid = agent["id"].upper()
pfx = f"AGENT_{aid}_"
fields = {}
for suffix in ("MODEL", "BASE_URL", "API_KEY"):
origin, value = await get_setting_origin(f"{pfx}{suffix}")
defn = SETTING_DEFS[f"{pfx}{suffix}"]
fields[suffix.lower()] = {
"configured": origin != "none",
"masked": mask_value(value, defn.sensitive),
"origin": origin,
}
# 生效模型 = 覆盖 → 层级默认(专家/终裁 env) → 全局 LLM_MODEL
tier_default = (
settings.LLM_AGGREGATOR_MODEL if agent["id"] == "aggregator" else settings.LLM_SPECIALIST_MODEL
)
effective_model = (
fields["model"]["masked"]
if fields["model"]["configured"]
else (tier_default or await get_runtime_value("LLM_MODEL"))
)
out.append(
{
"id": agent["id"],
"label": agent["label"],
"fields": fields,
"effective_model": effective_model,
}
)
return out
@router.get("/llm/models")
async def list_llm_models():
"""探测当前 LLM 服务可用的模型列表(OpenAI 兼容 GET /models)。
只读探测,不产生费用;配置缺失或服务不可达时返回 ok=false 与原因。
"""
base_url = (await get_runtime_value("LLM_BASE_URL")).rstrip("/")
api_key = await get_runtime_value("LLM_API_KEY")
if not base_url or not api_key:
return {"ok": False, "models": [], "detail": "LLM_BASE_URL 或 LLM_API_KEY 未配置"}
client = get_client()
start = time.monotonic()
try:
resp = await client.get(
f"{base_url}/models",
headers={"Authorization": f"Bearer {api_key}"},
timeout=httpx.Timeout(connect=10.0, read=20.0, write=10.0, pool=10.0),
)
except Exception as e:
return {
"ok": False,
"models": [],
"latency_ms": int((time.monotonic() - start) * 1000),
"detail": f"无法连接 LLM 服务: {e}",
}
latency = int((time.monotonic() - start) * 1000)
if resp.status_code in (401, 403):
return {"ok": False, "models": [], "latency_ms": latency, "detail": "密钥无效或无权限(HTTP 401/403)"}
if resp.status_code != 200:
return {"ok": False, "models": [], "latency_ms": latency, "detail": f"服务返回 HTTP {resp.status_code}"}
try:
data = resp.json()
except Exception:
return {"ok": False, "models": [], "latency_ms": latency, "detail": "响应不是合法 JSON"}
models: list[str] = []
items = data.get("data") if isinstance(data, dict) else None
if isinstance(items, list):
models = sorted(
str(m.get("id")) for m in items if isinstance(m, dict) and m.get("id")
)
if not models:
return {"ok": False, "models": [], "latency_ms": latency, "detail": "服务未返回模型列表"}
return {"ok": True, "models": models, "latency_ms": latency, "detail": f"{len(models)} 个可用模型"}
@router.put("/settings/{key}")
async def update_setting(key: str, body: SettingUpdateIn):
"""更新配置项(写入 app_settings 覆盖 .env)。传空值请改用 DELETE。"""
if key not in SETTING_DEFS:
raise HTTPException(404, f"不支持的配置项: {key}")
value = body.value.strip()
if not value:
raise HTTPException(400, "值不能为空;如需回落 .env 请调用清除接口")
await set_runtime_value(key, value)
defn = SETTING_DEFS[key]
return {"key": key, "masked": mask_value(value, defn.sensitive), "origin": "db"}
@router.delete("/settings/{key}")
async def clear_setting(key: str):
"""清除 DB 覆盖值,回落 .env 默认。"""
if key not in SETTING_DEFS:
raise HTTPException(404, f"不支持的配置项: {key}")
await clear_runtime_value(key)
origin, value = await get_setting_origin(key)
defn = SETTING_DEFS[key]
return {
"key": key,
"masked": mask_value(value, defn.sensitive),
"origin": origin,
}
# ── 连通性测试 ──────────────────────────────────────────────────
_TEST_TIMEOUT = 15
async def _probe(url: str, headers: dict | None = None, params: dict | None = None) -> dict:
"""单次 HTTP 探测,返回 (ok, status, latency_ms, detail)。不重试。"""
client = get_client()
start = time.monotonic()
try:
resp = await client.get(url, headers=headers, params=params, timeout=_TEST_TIMEOUT)
except Exception as e:
return {
"ok": False,
"status": None,
"latency_ms": int((time.monotonic() - start) * 1000),
"detail": f"无法连接: {e}",
}
latency = int((time.monotonic() - start) * 1000)
status = resp.status_code
if status == 200:
detail = "连接成功"
elif status in (401, 403):
detail = "服务可达,但密钥无效或无权限"
else:
detail = f"服务返回 HTTP {status}"
return {"ok": status == 200, "status": status, "latency_ms": latency, "detail": detail}
@router.post("/datasources/{name}/test")
async def test_datasource(name: str):
"""轻量连通性测试:真实请求上游一次,不触发任何入库。"""
src = next((s for s in _SOURCES if s["name"] == name), None)
if src is None:
raise HTTPException(404, f"未知数据源: {name}")
if name == "bzzoiro":
key = await get_runtime_value("BZZOIRO_KEY")
if not key:
return {"ok": False, "status": None, "latency_ms": 0, "detail": "BZZOIRO_KEY 未配置"}
base = (await get_runtime_value("BZZOIRO_BASE")).rstrip("/")
today = date.today().isoformat()
return await _probe(
f"{base}/events/",
headers={"Authorization": f"Token {key}", "Accept": "application/json"},
params={"date_from": today, "date_to": today},
)
raise HTTPException(404, f"未知数据源: {name}")
@router.post("/llm/ping")
async def llm_ping():
"""LLM 连通性测试(不依赖比赛)。只发一次 chat 请求验证配置。"""
from src.llm.provider import get_default_provider
p = await get_default_provider()
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}
# ── 数据源健康/最近采集状态(只读,不触发采集) ──────────────────────
@router.get("/ingest/status")
async def ingest_status(db: AsyncSession = Depends(get_db_read)):
"""数据源采集健康概览(bzzoiro 单源;只读,不触发任何采集)。"""
bzzoiro_key = await get_runtime_value("BZZOIRO_KEY")
bzzoiro_base = await get_runtime_value("BZZOIRO_BASE")
# 比赛覆盖
match_row = (
await db.execute(
select(
func.count().label("cnt"),
func.max(Match.match_date).label("latest_match_date"),
func.max(Match.created_at).label("latest_row_at"),
).where(Match.match_status == "finished")
)
).one()
# 统计覆盖(精确 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 = {
"name": "bzzoiro",
"label": "Bzzoiro",
"key_configured": bool(bzzoiro_key),
"base_url": (bzzoiro_base.rstrip("/") if bzzoiro_base else None) or settings.BZZOIRO_BASE,
"reachable": None, # 不主动探测
"last_success_at": (stats_row.latest_retrieved or match_row.latest_row_at),
"last_success_at_iso": (
stats_row.latest_retrieved or match_row.latest_row_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"),
}
return {"sources": [bzzoiro]}
@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:
"""从系统日志缓冲中查找某数据源的最近一次错误(仅作参考,非专用失败表)。"""
entries = get_entries(min_level="ERROR", keyword=source, limit=5)
if not entries:
return None
e = entries[0]
return {
"at": datetime.fromtimestamp(e["ts"]).isoformat(),
"logger": e["logger"],
"detail": e["message"][:200],
"note": "approx:来自内存日志缓冲,非专用采集失败表;进程重启后清零",
}
@router.get("/stats")
async def admin_stats(db: AsyncSession = Depends(get_db_read)):
"""管理区统计(只读):预测次数 + 比赛覆盖。轻量聚合,无 LLM 调用。"""
from sqlalchemy import func, text
from src.db.models import Prediction, Match, MatchStats, Standing
day_ago = datetime.now(timezone.utc) - timedelta(days=1)
week_ago = datetime.now(timezone.utc) - timedelta(days=7)
r = (
await db.execute(
select(
func.count().label("total"),
func.count().filter(Prediction.created_at >= day_ago).label("last_24h"),
func.count().filter(Prediction.created_at >= week_ago).label("last_7d"),
)
)
).one()
# 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]}
-141
View File
@@ -1,141 +0,0 @@
"""管理后台认证路由:密码登录 → HttpOnly Cookie 会话;支持在线修改密码。
管理员密码以 scrypt 哈希存于数据库(.env 明文仅作初始值,启动时自动迁移为哈希)。
修改密码会改变会话签名密钥,所有已登录会话随之失效,需重新登录。
"""
from __future__ import annotations
import logging
import secrets
import time
from collections import defaultdict, deque
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import BaseModel
from src.api.deps import (
SESSION_COOKIE,
auth_configured,
create_session_token,
get_session_secret,
require_admin,
verify_session_token,
)
from src.core import crypto
from src.core.config import settings
from src.core.runtime_config import (
get_admin_password_hash,
get_setting_origin,
set_admin_password_hash,
verify_admin_password,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
# 简易防爆破:10 分钟窗口内同一 IP 连续失败 5 次即锁定 10 分钟(内存态,重启清零)
_MAX_FAILS = 5
_WINDOW_SECONDS = 600
_fail_times: dict[str, deque[float]] = defaultdict(deque)
# 新密码强度要求
_MIN_PASSWORD_LEN = 8
_MAX_PASSWORD_LEN = 128
class LoginIn(BaseModel):
password: str
class PasswordChangeIn(BaseModel):
current_password: str
new_password: str
def _client_ip(request: Request) -> str:
"""获取客户端 IP,与限流共用同一套逻辑(防伪造)。"""
from src.api.deps import get_client_ip
return get_client_ip(request)
def _is_locked(ip: str) -> bool:
dq = _fail_times.get(ip)
if not dq:
return False
now = time.time()
while dq and now - dq[0] > _WINDOW_SECONDS:
dq.popleft()
return len(dq) >= _MAX_FAILS
@router.post("/login")
async def login(body: LoginIn, request: Request, response: Response):
ip = _client_ip(request)
if not (await get_admin_password_hash() or settings.ADMIN_PASSWORD):
raise HTTPException(status_code=503, detail="服务器未配置管理员密码,登录不可用")
if _is_locked(ip):
logger.warning("管理员登录尝试过于频繁 (ip=%s)", ip)
raise HTTPException(status_code=429, detail="失败次数过多,请 10 分钟后再试")
if not await verify_admin_password(body.password):
_fail_times[ip].append(time.time())
logger.warning("管理员登录失败 (ip=%s)", ip)
raise HTTPException(status_code=401, detail="密码错误")
_fail_times.pop(ip, None)
# Code Review High-4: 生产环境(HHTTPS)下 Cookie 必须带 Secure,防中间人窃取
secure = settings.APP_ENV == "production"
response.set_cookie(
key=SESSION_COOKIE,
value=create_session_token(await get_session_secret()),
max_age=settings.ADMIN_SESSION_TTL_HOURS * 3600,
httponly=True,
samesite="lax",
path="/",
secure=secure,
)
logger.info("管理员登录成功 (ip=%s)", ip)
return {"ok": True, "expires_in_hours": settings.ADMIN_SESSION_TTL_HOURS}
@router.post("/logout")
async def logout(response: Response):
response.delete_cookie(key=SESSION_COOKIE, path="/")
return {"ok": True}
@router.get("/me")
async def me(request: Request):
"""前端登录门禁探测。未启用鉴权时视为已登录(本地开发模式)。"""
token = request.cookies.get(SESSION_COOKIE)
authenticated = not await auth_configured() or bool(
token and verify_session_token(token, await get_session_secret())
)
has_hash = bool(await get_admin_password_hash())
return {
"authenticated": authenticated,
"enabled": await auth_configured(),
"password_origin": "db" if has_hash else ("env" if settings.ADMIN_PASSWORD else "none"),
}
@router.post("/change-password", dependencies=[Depends(require_admin)])
async def change_password(body: PasswordChangeIn, request: Request, response: Response):
"""修改管理员密码:验证当前密码 → 写运行时覆盖 → 清除会话(全端登出)。"""
if not await auth_configured():
raise HTTPException(status_code=503, detail="服务器未配置管理员密码,无法修改")
if not await verify_admin_password(body.current_password):
logger.warning("修改密码失败:当前密码错误 (ip=%s)", _client_ip(request))
raise HTTPException(status_code=401, detail="当前密码错误")
new = body.new_password
if not (_MIN_PASSWORD_LEN <= len(new) <= _MAX_PASSWORD_LEN):
raise HTTPException(status_code=400, detail=f"新密码长度需在 {_MIN_PASSWORD_LEN}-{_MAX_PASSWORD_LEN} 位之间")
if await verify_admin_password(new):
raise HTTPException(status_code=400, detail="新密码不能与当前密码相同")
await set_admin_password_hash(crypto.hash_password(new))
# 密码即会话签名密钥,修改后所有旧会话失效;主动清除当前 Cookie 要求重新登录
response.delete_cookie(key=SESSION_COOKIE, path="/")
logger.info("管理员密码已修改 (ip=%s),所有会话已失效", _client_ip(request))
return {"ok": True, "message": "密码已修改,请用新密码重新登录"}
+7 -13
View File
@@ -6,7 +6,7 @@ import logging
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from src.api.deps import require_admin
from src.api.deps import require_admin_key
from src.llm.backtest import run_backtest
logger = logging.getLogger(__name__)
@@ -23,21 +23,18 @@ class BacktestRequest(BaseModel):
model: str | None = Field(None, description="指定模型 (空=默认)")
@router.post("/backtest", dependencies=[Depends(require_admin)])
@router.post("/backtest", dependencies=[Depends(require_admin_key)])
async def backtest(req: BacktestRequest):
"""对历史比赛运行回测。
该接口会对每场已完赛比赛各发起一次 LLM 预测,成本高 —— 因此需要
管理员鉴权(require_admin)。
`X-API-Key` 鉴权(见审查报告 P2-7)。
对每场已完赛比赛:
1. 用比赛之前的数据构建上下文 (防未来信息泄漏,cutoff=match_date-1天)
2. 调 LLM 预测(强制 use_cache=False,避免缓存命中导致反复 settle 同一行)
3. 用实际比分回填(settle)
1. 用比赛之前的数据构建上下文 (防未来信息泄漏)
2. 调 LLM 预测
3. 用实际比分回填
4. 统计准确率 / RMSE / 校准度
限流:单请求上限 200 场(默认 20),避免一次打爆 LLM 额度。
回测写入 run_type='backtest',与实盘(live)互不覆盖(唯一键含 run_type)。
"""
try:
summary = await run_backtest(
@@ -56,11 +53,10 @@ async def backtest(req: BacktestRequest):
"summary": {
"total": summary.total,
"scored": summary.scored,
"success": summary.success,
"degraded": summary.degraded,
"accuracy_1x2": summary.accuracy_1x2,
"avg_score_rmse": summary.avg_score_rmse,
"avg_subjective_confidence": summary.avg_subjective_confidence,
"calibration": summary.calibration,
},
"results": [
{
@@ -68,8 +64,6 @@ async def backtest(req: BacktestRequest):
"league_code": r.league_code,
"home_team": r.home_team,
"away_team": r.away_team,
"home_team_zh": r.home_team_zh,
"away_team_zh": r.away_team_zh,
"match_date": r.match_date,
"actual_score": f"{r.actual_home}-{r.actual_away}",
"actual_1x2": r.actual_1x2,
+9 -38
View File
@@ -3,9 +3,9 @@ from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException
from src.api.deps import require_admin
from src.api.deps import require_admin_key
from src.api.schemas import EvalSummaryOut, SettleRequest
from src.db.base import AsyncSession, get_db, get_db_read
from src.llm.eval import get_eval_summary, settle_prediction
@@ -15,50 +15,21 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1", tags=["eval"])
@router.post("/eval/settle", dependencies=[Depends(require_admin)])
@router.post("/eval/settle", dependencies=[Depends(require_admin_key)])
async def settle(req: SettleRequest, db: AsyncSession = Depends(get_db)):
"""回填实际结果。
status 为 degraded/failed 的预测无法结算(返回 400);
记录不存在返回 404。
"""
"""回填实际结果。"""
try:
pred = await settle_prediction(req.prediction_id, req.home_goals, req.away_goals)
return {"id": pred.id, "settled": pred.settled}
except ValueError as e:
msg = str(e)
# degraded/failed 拒绝:明确的 400,而非与"未找到"混为一谈
if "无法结算" in msg:
logger.warning("settle rejected: %s", msg)
raise HTTPException(400, msg)
logger.warning("settle failed: %s", msg)
logger.warning("settle failed: %s", e)
raise HTTPException(404, "预测记录不存在")
except Exception as e:
logger.exception("settle error")
raise HTTPException(500, "回填失败,请查看服务器日志")
@router.get("/eval/summary", response_model=EvalSummaryOut, dependencies=[Depends(require_admin)])
async def eval_summary(
limit: int = Query(1000, ge=1, le=10000, description="最大评估条数"),
provider: str | None = Query(None, description="按提供商筛选"),
model: str | None = Query(None, description="按模型筛选"),
prompt_version: str | None = Query(None, description="按 prompt 版本筛选"),
mode: str | None = Query(None, description="按模式筛选(single/multi)"),
league_code: str | None = Query(None, description="按联赛代码筛选(如 E0/SP1)"),
db: AsyncSession = Depends(get_db_read),
):
"""提供商/模型准确率对比。
P3-4: 默认评估最近 1000 条,可通过 limit 调整。
支持按 provider / model / prompt_version / mode / league_code 筛选。
只统计 status=success 且预测比分齐全的已结算预测,degraded 不计入。
"""
return await get_eval_summary(
limit=limit,
provider=provider,
model=model,
prompt_version=prompt_version,
mode=mode,
league_code=league_code,
)
@router.get("/eval/summary", response_model=EvalSummaryOut)
async def eval_summary():
"""提供商/模型准确率对比。"""
return await get_eval_summary()
+41 -80
View File
@@ -1,99 +1,60 @@
"""采集路由(bzzoiro 单一数据源)。
任务类型:
events — 比赛日程/比分(/events/)
standings — 联赛积分榜(/leagues/{id}/standings/)
stats — 已完赛比赛详细统计回填(/events/{id}/stats/)
all — 依次执行以上三项
"""
"""采集路由"""
from __future__ import annotations
import asyncio
import logging
from fastapi import APIRouter, Depends, HTTPException
from src.api.deps import require_admin
from src.api.schemas import IngestBzzoiroRequest
from src.data.config import BZZOIRO_LEAGUE_IDS
from src.data.bzzoiro import ingest_bzzoiro_event_stats, ingest_bzzoiro_standings
from src.api.deps import require_admin_key
from src.api.schemas import IngestBzzoiroRequest, IngestResponse, IngestUnderstatRequest, IngestInjuriesRequest, IngestSimpleResponse
from src.data.sources import get_source
from src.data.injuries import ingest_injuries
from src.db.unit_of_work import get_uow
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1", tags=["ingest"])
# 后台采集任务注册表:持强引用防止被 GC
_background_tasks: set[asyncio.Task] = set()
VALID_TASKS = {"events", "standings", "stats", "all"}
def _spawn(coro) -> None:
"""启动后台采集任务;异常已在任务内记录到系统日志。"""
task = asyncio.create_task(coro)
_background_tasks.add(task)
task.add_done_callback(_background_tasks.discard)
@router.post("/ingest/bzzoiro", dependencies=[Depends(require_admin)])
@router.post("/ingest/bzzoiro", response_model=IngestResponse, dependencies=[Depends(require_admin_key)])
async def ingest_bzzoiro_route(req: IngestBzzoiroRequest):
"""触发 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())
task_label = {"events": "比赛数据", "standings": "积分榜", "stats": "统计回填", "all": "全量(比赛+积分榜+统计)"}[req.task]
_spawn(_run_bzzoiro(req.task, leagues, req))
return {
"ok": True,
"message": f"采集任务已启动(后台执行,任务: {task_label}),请在「系统日志」查看进度与结果",
}
async def _run_bzzoiro(task: str, leagues: list[str], req: IngestBzzoiroRequest) -> None:
"""后台执行 bzzoiro 采集:上游限速时单次可能耗时数分钟,必须脱离请求生命周期。"""
"""触发 bzzoiro 采集。"""
source = get_source("bzzoiro")
try:
if task in ("events", "all"):
statuses = [req.status] if req.status else ["finished", "scheduled"]
source = get_source("bzzoiro")
merged: dict = {"leagues": {}, "total_inserted": 0, "total_updated": 0, "errors": []}
# D2 修复: 按联赛分批提交,避免超长事务
for code in leagues:
for st in statuses:
async with get_uow() as session:
r = await source.ingest(
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,
async with get_uow() as session:
result = await source.ingest(
session,
leagues=req.leagues,
date_from=req.date_from,
date_to=req.date_to,
status=req.status,
)
if merged["errors"]:
logger.warning("bzzoiro 比赛采集错误 %d 条: %s", len(merged["errors"]), merged["errors"][:3])
return IngestResponse(**result)
except Exception as e:
logger.exception("bzzoiro ingest failed")
raise HTTPException(500, "数据采集失败,请查看服务器日志")
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
)
if r["errors"]:
logger.warning("bzzoiro 统计回填错误 %d 条: %s", len(r["errors"]), r["errors"][:3])
except Exception:
logger.exception("bzzoiro 采集任务失败(task=%s)", task)
@router.post("/ingest/understat", response_model=IngestSimpleResponse, dependencies=[Depends(require_admin_key)])
async def ingest_understat_route(req: IngestUnderstatRequest):
"""触发 understat xG 回填。"""
source = get_source("understat")
try:
async with get_uow() as session:
result = await source.ingest(session, league=req.league, season=req.season)
return IngestSimpleResponse(**result)
except Exception as e:
logger.exception("understat ingest failed")
raise HTTPException(500, "xG 回填失败,请查看服务器日志")
@router.post("/ingest/injuries", response_model=IngestSimpleResponse, dependencies=[Depends(require_admin_key)])
async def ingest_injuries_route(req: IngestInjuriesRequest):
"""触发伤停采集。"""
try:
async with get_uow() as session:
result = await ingest_injuries(session, date=req.date)
return IngestSimpleResponse(**result)
except Exception as e:
logger.exception("injuries ingest failed")
raise HTTPException(500, "伤停采集失败,请查看服务器日志")
+11 -232
View File
@@ -4,35 +4,17 @@ from __future__ import annotations
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, or_, select
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from src.api.deps import require_admin
from src.api.schemas import MatchListOut, MatchOut, PredictionOut
from src.api.schemas import MatchListOut, MatchOut
from src.db.base import AsyncSession, get_db_read
from src.db.models import League, Match, Prediction, Standing
from src.db.models import League, Match
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])
async def list_leagues(db: AsyncSession = Depends(get_db_read)):
stmt = select(League).order_by(League.name)
result = await db.execute(stmt)
@@ -58,19 +40,10 @@ async def list_matches(
last_date_str, last_id_str = cursor.split("|", 1)
last_date = datetime.fromisoformat(last_date_str)
last_id = int(last_id_str)
# 游标方向必须与排序方向一致:
# - scheduled(ASC):取「更大」的未开赛场次
# - 其它(DESC):取「更小」的已赛场次
if status == "scheduled":
q = q.where(
(Match.match_date > last_date) |
((Match.match_date == last_date) & (Match.id > last_id))
)
else:
q = q.where(
(Match.match_date < last_date) |
((Match.match_date == last_date) & (Match.id < last_id))
)
q = q.where(
(Match.match_date < last_date) |
((Match.match_date == last_date) & (Match.id < last_id))
)
except (ValueError, AttributeError):
pass
@@ -89,20 +62,9 @@ async def list_matches(
d = datetime.strptime(date, "%Y-%m-%d")
except ValueError:
raise HTTPException(400, "date 格式应为 YYYY-MM-DD")
# 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)
q = q.where(Match.match_date >= d, Match.match_date < d + timedelta(days=1))
# 未开赛按日期正序(最近的排最前,便于预测);其余按日期倒序(最新赛果在前)
if status == "scheduled":
order = (Match.match_date.asc(), Match.id.asc())
else:
order = (Match.match_date.desc(), Match.id.desc())
rows = (await db.execute(q.order_by(*order).limit(limit + 1))).scalars().all()
rows = (await db.execute(q.order_by(Match.match_date.desc(), Match.id.desc()).limit(limit + 1))).scalars().all()
has_more = len(rows) > limit
rows = rows[:limit]
@@ -137,26 +99,12 @@ async def list_matches(
async def get_match(match_id: int, db: AsyncSession = Depends(get_db_read)):
stmt = (
select(Match)
.options(
selectinload(Match.league),
selectinload(Match.home_team),
selectinload(Match.away_team),
selectinload(Match.stats),
)
.options(selectinload(Match.league), selectinload(Match.home_team), selectinload(Match.away_team))
.where(Match.id == match_id)
)
m = (await db.execute(stmt)).scalar_one_or_none()
if m is None:
raise HTTPException(404, "match not found")
# 最近预测(倒序,最多 5 条)——复用 PredictionOut 结构,只读,不触发 LLM
preds = (
await db.execute(
select(Prediction)
.where(Prediction.match_id == match_id)
.order_by(Prediction.created_at.desc())
.limit(5)
)
).scalars().all()
return MatchOut(
id=m.id,
league_code=m.league.code if m.league else None,
@@ -172,173 +120,4 @@ async def get_match(match_id: int, db: AsyncSession = Depends(get_db_read)):
match_stage=m.match_stage,
home_xg=m.stats.home_xg if m.stats else None,
away_xg=m.stats.away_xg if m.stats else None,
stats=_stats_dict(m.stats) if m.stats else None,
recent_predictions=[
PredictionOut(
id=p.id, match_id=p.match_id, provider=p.provider, model=p.model,
prompt_version=p.prompt_version, mode=p.mode or "single",
pred_home_goals=p.pred_home_goals, pred_away_goals=p.pred_away_goals,
alt_pred_home_goals=p.alt_pred_home_goals, alt_pred_away_goals=p.alt_pred_away_goals,
pred_1x2=p.pred_1x2, subjective_confidence=p.subjective_confidence,
reasoning=p.reasoning, status=p.status or "success",
agent_outputs=p.agent_outputs, agent_weights=p.agent_weights,
created_at=p.created_at, actual_home_goals=p.actual_home_goals,
actual_away_goals=p.actual_away_goals, settled=p.settled,
)
for p in preds
],
)
@router.get("/matches/{match_id}/context", dependencies=[Depends(require_admin)])
async def match_context(match_id: int, db: AsyncSession = Depends(get_db_read)):
"""比赛上下文(只读,不触发 LLM):双方近况 + 历史交锋。
全部基于现有数据聚合:
- recent_home / recent客队:该队最近 5 场已完赛(进球/结果)
- h2h:双方最近 5 次交手
若数据不足,对应列表为空(前端展示空态)。
"""
m = (
await db.execute(
select(Match)
.options(selectinload(Match.home_team), selectinload(Match.away_team))
.where(Match.id == match_id)
)
).scalar_one_or_none()
if m is None:
raise HTTPException(404, "match not found")
home_id = m.home_team_id
away_id = m.away_team_id
def _row_to_dict(row):
return {
"match_date": row.match_date.isoformat() if row.match_date else None,
"home_team": row.home_team.name_zh or row.home_team.name if row.home_team else None,
"away_team": row.away_team.name_zh or row.away_team.name if row.away_team else None,
"home_goals": row.home_goals,
"away_goals": row.away_goals,
}
# 主队近况(已完赛,含主/客场)
home_recent = (
await db.execute(
select(Match)
.where(Match.match_status == "finished", Match.home_team_id == home_id)
.order_by(Match.match_date.desc())
.limit(5)
.options(selectinload(Match.home_team), selectinload(Match.away_team))
)
).scalars().all()
# 客队近况
away_recent = (
await db.execute(
select(Match)
.where(Match.match_status == "finished", Match.away_team_id == away_id)
.order_by(Match.match_date.desc())
.limit(5)
.options(selectinload(Match.home_team), selectinload(Match.away_team))
)
).scalars().all()
# 历史交锋(双方已完赛)
h2h = (
await db.execute(
select(Match)
.where(
Match.match_status == "finished",
or_(
(Match.home_team_id == home_id) & (Match.away_team_id == away_id),
(Match.home_team_id == away_id) & (Match.away_team_id == home_id),
),
)
.order_by(Match.match_date.desc())
.limit(5)
.options(selectinload(Match.home_team), selectinload(Match.away_team))
)
).scalars().all()
return {
"home_recent": [_row_to_dict(r) for r in home_recent],
"away_recent": [_row_to_dict(r) for r in away_recent],
"h2h": [_row_to_dict(r) for r in h2h],
}
@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())}
+24 -130
View File
@@ -1,21 +1,15 @@
"""预测路由。
安全改进:
- 限流: 每分钟 10 次 / IP(内存实现)
- DB 连接: 短 session 模式,LLM 调用期间不持有连接
"""
"""预测路由。"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.orm import selectinload
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.db.base import AsyncSession, get_db_read, short_read
from src.db.models import Match, Prediction
from src.db.base import AsyncSession, get_db, get_db_read
from src.db.models import Prediction
from src.llm.predict import predict_match, PredictResult
logger = logging.getLogger(__name__)
@@ -23,26 +17,9 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1", tags=["predict"])
@router.post("/predict", response_model=PredictOut, dependencies=[Depends(rate_limit_predict)])
async def predict(req: PredictRequest, request: Request):
"""对一场比赛调 LLM 预测。mode=multi(默认,5专家+终裁)single 或 baseline。
公开接口,仅做限流保护(不要求登录)。
DB 连接优化:
1. 短 read session 检查比赛存在性/状态
2. 释放连接后调用 LLM(可能几十秒)
3. 短 write session 保存 Prediction
"""
# 1. 短 read session: 检查比赛(连接立即释放)
async with short_read() as session:
m = await session.get(Match, req.match_id)
if m is None:
raise HTTPException(404, "match not found")
if m.match_status == "finished":
raise HTTPException(400, "该比赛已完赛,不再支持预测")
# 2. 预测调用(不持有任何 DB 连接)
@router.post("/predict", response_model=PredictOut)
async def predict(req: PredictRequest, db: AsyncSession = Depends(get_db)):
"""对一场比赛调 LLM 预测。mode=multi(默认,5专家+终裁)single"""
try:
result = await predict_match(
req.match_id,
@@ -51,9 +28,6 @@ async def predict(req: PredictRequest, request: Request):
mode=req.mode,
)
except ValueError as e:
msg = str(e)
if "已结算" in msg:
raise HTTPException(409, msg)
logger.warning("predict validation error: %s", e)
raise HTTPException(404, "比赛不存在")
except RuntimeError as e:
@@ -63,78 +37,26 @@ async def predict(req: PredictRequest, request: Request):
logger.exception("predict unexpected error")
raise HTTPException(500, "预测失败,请查看服务器日志")
# baseline 模式:结果已是 dict,需独立落库(prediction_id)
if req.mode == "baseline":
prediction_id = await _persist_baseline(req.match_id, result)
else:
prediction_id = result.prediction_id
# 3. 结果映射(无 DB 访问)
logger.info(
"预测完成 match=%s mode=%s pred=%s:%s (%s)",
req.match_id, req.mode,
result.get("pred_home_goals") if isinstance(result, dict) else result.pred_home_goals,
result.get("pred_away_goals") if isinstance(result, dict) else result.pred_away_goals,
result.get("pred_1x2") if isinstance(result, dict) else result.pred_1x2,
)
result_dict = result if isinstance(result, dict) else None
# single / multi 两种结果统一映射
return PredictOut(
prediction_id=prediction_id,
provider=result.get("provider") if result_dict else result.provider,
model=result.get("model") if result_dict else result.model,
prompt_version=result.get("prompt_version") if result_dict else getattr(result, "prompt_version", None),
mode=req.mode,
pred_home_goals=result.get("pred_home_goals") if result_dict else result.pred_home_goals,
pred_away_goals=result.get("pred_away_goals") if result_dict else result.pred_away_goals,
alt_pred_home_goals=result.get("alt_pred_home_goals") if result_dict else result.alt_pred_home_goals,
alt_pred_away_goals=result.get("alt_pred_away_goals") if result_dict else result.alt_pred_away_goals,
pred_1x2=result.get("pred_1x2") if result_dict else result.pred_1x2,
subjective_confidence=result.get("subjective_confidence") if result_dict else result.subjective_confidence,
reasoning=result.get("reasoning") if result_dict else result.reasoning,
status=result.get("status", "success") if result_dict else getattr(result, "status", "success"),
agent_outputs=result.get("agent_outputs") if result_dict else getattr(result, "agent_outputs", None),
agent_weights=result.get("agent_weights") if result_dict else getattr(result, "agent_weights", None),
context=result.get("context", "") if result_dict else result.context,
latency_ms=result.get("latency_ms", 0) if result_dict else result.latency_ms,
prompt_tokens=result.get("prompt_tokens") if result_dict else getattr(result, "prompt_tokens", None),
completion_tokens=result.get("completion_tokens") if result_dict else getattr(result, "completion_tokens", None),
rate_limit_remaining=get_predict_rate_limit_remaining(request),
prediction_id=result.prediction_id,
provider=result.provider,
model=result.model,
prompt_version=getattr(result, "prompt_version", None),
mode=getattr(result, "mode", "single"),
pred_home_goals=result.pred_home_goals,
pred_away_goals=result.pred_away_goals,
pred_1x2=result.pred_1x2,
subjective_confidence=result.subjective_confidence,
reasoning=result.reasoning,
agent_outputs=getattr(result, "agent_outputs", None),
agent_weights=getattr(result, "agent_weights", None),
context=result.context,
latency_ms=result.latency_ms,
)
async def _persist_baseline(match_id: int, baseline: dict) -> int:
"""将基线预测结果写入 prediction 表,复用 upsert 语义。"""
from src.db.unit_of_work import get_uow
from src.llm.predict import _upsert_prediction
async with get_uow() as session:
pred = await _upsert_prediction(
session,
match_id=match_id,
provider_name="baseline",
model="baseline",
mode="baseline",
run_type="live", # baseline 是 live 预测的变体,符合 ck_run_type_enum
values={
"prompt_version": "baseline_v1",
"prompt_tokens": 0,
"completion_tokens": 0,
"latency_ms": 0,
"pred_home_goals": baseline["pred_home_goals"],
"pred_away_goals": baseline["pred_away_goals"],
"pred_1x2": baseline["pred_1x2"],
"subjective_confidence": baseline["subjective_confidence"],
"reasoning": baseline["reasoning"],
"raw_response": baseline.get("raw", baseline),
"status": "success",
},
)
return pred.id
@router.get("/predictions", response_model=list[PredictionOut], dependencies=[Depends(require_admin)])
@router.get("/predictions", response_model=list[PredictionOut])
async def list_predictions(
match_id: int | None = None,
limit: int = Query(50, ge=1, le=200),
@@ -155,44 +77,20 @@ async def list_predictions(
mode=p.mode or "single",
pred_home_goals=p.pred_home_goals,
pred_away_goals=p.pred_away_goals,
alt_pred_home_goals=p.alt_pred_home_goals,
alt_pred_away_goals=p.alt_pred_away_goals,
pred_1x2=p.pred_1x2,
subjective_confidence=p.subjective_confidence,
reasoning=p.reasoning,
status=p.status or "success",
agent_outputs=p.agent_outputs,
agent_weights=p.agent_weights,
created_at=p.created_at,
actual_home_goals=p.actual_home_goals,
actual_away_goals=p.actual_away_goals,
settled=p.settled,
match=_match_dict(p.match) if p.match else None,
)
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)
async def get_prediction(prediction_id: int, db: AsyncSession = Depends(get_db_read)):
p = await db.get(Prediction, prediction_id)
if p is None:
@@ -206,14 +104,10 @@ async def get_prediction(prediction_id: int, db: AsyncSession = Depends(get_db_r
mode=p.mode or "single",
pred_home_goals=p.pred_home_goals,
pred_away_goals=p.pred_away_goals,
alt_pred_home_goals=p.alt_pred_home_goals,
alt_pred_away_goals=p.alt_pred_away_goals,
pred_1x2=p.pred_1x2,
subjective_confidence=p.subjective_confidence,
reasoning=p.reasoning,
status=p.status or "success",
agent_outputs=p.agent_outputs,
agent_weights=p.agent_weights,
created_at=p.created_at,
actual_home_goals=p.actual_home_goals,
actual_away_goals=p.actual_away_goals,
-184
View File
@@ -1,184 +0,0 @@
"""定时任务管理路由。"""
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} 次)"}
+15 -57
View File
@@ -1,7 +1,7 @@
"""Pydantic schemas。"""
from __future__ import annotations
from datetime import date, datetime
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field
@@ -29,10 +29,6 @@ class MatchOut(BaseModel):
match_stage: str | None
home_xg: float | None = None
away_xg: float | None = None
# 比赛详细统计(bzzoiro /events/{id}/stats/),无统计为 None
stats: dict | None = None
# 该场比赛的最近预测摘要(按时间倒序,最多 5 条;无预测为空)
recent_predictions: list[PredictionOut] = []
class MatchListOut(BaseModel):
@@ -46,14 +42,7 @@ class PredictRequest(BaseModel):
provider: str | None = None
model: str | None = None
prompt_version: str | None = None
mode: str = Field(
"multi",
pattern="^(multi|single|baseline)$",
description="multi(默认,5专家+终裁) | single(单次) | baseline(极简统计基线,不调用 LLM)",
)
use_cache: bool = True
backtest: bool = False
cutoff_at: str | None = Field(None, description="显式截止时间 ISO8601,用于回测防未来信息")
mode: str = "multi" # multi(默认, 5专家+终裁) | single(单次调用)
class PredictOut(BaseModel):
@@ -64,18 +53,9 @@ class PredictOut(BaseModel):
mode: str = "single"
pred_home_goals: float | None
pred_away_goals: float | None
alt_pred_home_goals: int | None = None
alt_pred_away_goals: int | None = None
pred_1x2: str | None
subjective_confidence: float | None
reasoning: str | None
status: str = "success"
# 成本信息(可选;单次/多专家均有)
latency_ms: int | None = None
prompt_tokens: int | None = None
completion_tokens: int | None = None
# 限流提示:当请求被节流时告知用户剩余配额(可选)
rate_limit_remaining: int | None = None
agent_outputs: list[dict] | None = None
agent_weights: dict | None = None
context: str
@@ -91,30 +71,21 @@ class PredictionOut(BaseModel):
mode: str = "single"
pred_home_goals: float | None
pred_away_goals: float | None
alt_pred_home_goals: int | None = None
alt_pred_away_goals: int | None = None
pred_1x2: str | None
subjective_confidence: float | None
reasoning: str | None
status: str = "success"
agent_outputs: list[dict] | None = None
agent_weights: dict | None = None
created_at: datetime
actual_home_goals: int | None
actual_away_goals: int | None
settled: bool
# 比赛信息(可选,列表接口不返回以减少 payload)
match: dict | None = None
class IngestBzzoiroRequest(BaseModel):
leagues: list[str] = Field(default_factory=list, description="联赛代码列表,如 ['E0','SP1'];空 = 全部已知联赛")
leagues: list[str] = Field(..., description="联赛代码列表,如 ['E0','SP1']")
date_from: str | None = None
date_to: str | None = None
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';空 = 当前赛季")
status: str = "finished"
class IngestResponse(BaseModel):
@@ -124,29 +95,21 @@ class IngestResponse(BaseModel):
errors: list[str] = []
class ScheduleIn(BaseModel):
id: str = Field(..., description="任务唯一标识,如 'daily-events'")
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 IngestUnderstatRequest(BaseModel):
league: str = Field(..., description="联赛代码,如 'E0'")
season: int = Field(..., description="赛季起始年,如 2025 表示 2025-2026 赛季")
class ScheduleUpdate(BaseModel):
task: str | None = None
cron: str | None = None
leagues: list[str] | None = None
enabled: bool | None = None
class IngestInjuriesRequest(BaseModel):
date: str | None = Field(None, description="日期 YYYY-MM-DD,为空则采集当天")
class ScheduleOut(BaseModel):
id: str
task: str
cron: str
leagues: str | None
enabled: bool
last_run_at: str | None
last_status: str | None
class IngestSimpleResponse(BaseModel):
count: int = 0
updated: int = 0
skipped: int = 0
unmatched: int = 0
errors: list[str] = []
class SettleRequest(BaseModel):
@@ -157,8 +120,3 @@ class SettleRequest(BaseModel):
class EvalSummaryOut(BaseModel):
summary: list[dict[str, Any]]
total_settled: int
filtered_settled: int
evaluated: int
skipped_degraded: int
skipped_incomplete: int = 0
+4 -23
View File
@@ -11,9 +11,6 @@ class Settings(BaseSettings):
# --- app ---
APP_ENV: str = "development"
LOG_LEVEL: str = "INFO"
# 生产环境强制要求管理鉴权配置,即使 APP_ENV=production 也生效。
# True 时若 auth_configured() 为 False 则拒绝(503),development 保持 fail-open。
REQUIRE_ADMIN_AUTH: bool = False
# --- database ---
DATABASE_URL: str = "postgresql+asyncpg://football:football@localhost:5432/football"
@@ -31,12 +28,7 @@ class Settings(BaseSettings):
# --- data sources ---
BZZOIRO_KEY: str = ""
BZZOIRO_BASE: str = "https://sports.bzzoiro.com/api/v2"
# --- 代理头信任 ---
# 为 True 时才解析 X-Forwarded-For,否则只用 request.client.host。
# 公网部署时应设为 True,并确保仅 Nginx 等可信反代能访问 API,
# 且 Nginx 层已覆盖真实 IP(X-Real-IP / proxy_protocol)。
TRUST_PROXY_HEADERS: bool = False
API_FOOTBALL_KEY: str = ""
# --- CORS ---
CORS_ORIGINS: str = "http://localhost:5173,http://localhost:3000"
@@ -53,21 +45,10 @@ class Settings(BaseSettings):
DB_POOL_RECYCLE: int = 1800
# --- 管理接口鉴权 ---
# 管理后台登录密码(POST /api/v1/auth/login),登录后颁发 HttpOnly Cookie 会话
# 采集 / 回测等高成本或写入型接口同样需要此密码或下方 API Key
# 两者均留空表示「未启用鉴权」(本地开发默认),生产环境必须至少设置一项
# 注意:.env 中的 ADMIN_PASSWORD 是初始值;后台修改密码后以数据库中的
# scrypt 哈希为准,建议随后删除此明文项。
ADMIN_PASSWORD: str = ""
# 采集 / 回测等高成本或写入型接口需要此 Key(请求头 X-API-Key)
# 留空表示「未启用鉴权」(本地开发默认),生产环境必须设置
# 见审查报告 P2-7:ingest/backtest 无鉴权可被任意调用并烧掉 LLM 额度
ADMIN_API_KEY: str = ""
# 管理后台会话有效期(小时)
ADMIN_SESSION_TTL_HOURS: int = 168
# --- 加密主密钥 ---
# 敏感配置(数据源/LLM 的 API Key)入库加密、会话签名都由它派生。
# 只存于部署机 .env,切勿入库或提交代码。生成: openssl rand -base64 32
# 变更后已加密配置将无法解密(需在后台重新保存)。
SECRET_KEY: str = ""
settings = Settings()
-104
View File
@@ -1,104 +0,0 @@
"""安全原语:对称加密(Fernet/AES)与密码哈希(scrypt)。
- API Key 等需要原文调用的敏感值:入库前用 SECRET_KEY 派生的 Fernet 密钥加密,
存储格式 `enc:v1:<token>`;读取时解密SECRET_KEY 只存于部署机 .env,不入库
- 管理员密码:只存 scrypt 哈希(单向,不可逆),验证用,永远不需要还原原文
`enc:v1:` 前缀 + 透传设计使旧明文数据无需停机即可共存,由启动迁移一次性加密
"""
from __future__ import annotations
import base64
import hashlib
import hmac as _hmac
import logging
import secrets
from cryptography.fernet import Fernet, InvalidToken
from src.core.config import settings
logger = logging.getLogger(__name__)
_ENC_PREFIX = "enc:v1:"
# scrypt 参数(OWASP 推荐: n=2^17 更强,取 n=2^15 平衡 NAS CPU)
_SCRYPT_N = 2**15
_SCRYPT_R = 8
_SCRYPT_P = 1
# OpenSSL 默认 maxmem 限制约 32MB,显式放宽到 128MB
_SCRYPT_MAXMEM = 128 * 1024 * 1024
def _fernet() -> Fernet:
"""由 SECRET_KEY 确定性派生 Fernet 密钥(任意字符串输入均可)。
SECRET_KEY 未配置时回落派生自 DATABASE_URL(仅为不让开发环境崩溃;
生产必须显式配置,否则加密强度受限 启动时会打 warning)
"""
raw = settings.SECRET_KEY
if not raw:
logger.warning(
"SECRET_KEY 未设置,加密密钥回落派生自 DATABASE_URL。"
"请在 .env 配置强随机 SECRET_KEY(openssl rand -base64 32)。"
)
raw = f"fallback:{settings.DATABASE_URL}"
digest = hashlib.sha256(raw.encode()).digest()
return Fernet(base64.urlsafe_b64encode(digest))
def encrypt_value(plaintext: str) -> str:
"""加密敏感值,带版本前缀;空值原样返回。"""
if not plaintext:
return plaintext
token = _fernet().encrypt(plaintext.encode()).decode()
return f"{_ENC_PREFIX}{token}"
def decrypt_value(stored: str) -> str:
"""解密 `enc:v1:` 前缀的值;无前缀(旧明文)原样返回,便于平滑迁移。"""
if not stored or not stored.startswith(_ENC_PREFIX):
return stored
token = stored[len(_ENC_PREFIX):]
try:
return _fernet().decrypt(token.encode()).decode()
except InvalidToken:
# 密钥不匹配(通常是 SECRET_KEY 变了):报错而非静默返回错误数据
raise ValueError(
"敏感配置解密失败:SECRET_KEY 与加密时不一致。"
"恢复原 SECRET_KEY 或在后台重新保存对应配置项。"
) from None
def is_encrypted(stored: str) -> bool:
return bool(stored) and stored.startswith(_ENC_PREFIX)
def hash_password(password: str) -> str:
"""scrypt 哈希,存储格式 scrypt$N$r$p$salt_hex$dk_hex。"""
salt = secrets.token_bytes(16)
dk = hashlib.scrypt(
password.encode(), salt=salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P,
dklen=32, maxmem=_SCRYPT_MAXMEM,
)
return f"scrypt${_SCRYPT_N}${_SCRYPT_R}${_SCRYPT_P}${salt.hex()}${dk.hex()}"
def verify_password(password: str, stored: str) -> bool:
"""校验密码与存储的 scrypt 哈希是否匹配。"""
try:
algo, n, r, p, salt_hex, dk_hex = stored.split("$")
if algo != "scrypt":
return False
dk = hashlib.scrypt(
password.encode(),
salt=bytes.fromhex(salt_hex),
n=int(n),
r=int(r),
p=int(p),
dklen=len(bytes.fromhex(dk_hex)),
maxmem=_SCRYPT_MAXMEM,
)
return _hmac.compare_digest(dk, bytes.fromhex(dk_hex))
except (ValueError, TypeError):
return False
+3 -1
View File
@@ -2,7 +2,9 @@
使用方:
- 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)
调用方可通过 `timeout` 参数覆盖 per-request 超时
-78
View File
@@ -1,78 +0,0 @@
"""内存日志缓冲:供后台「系统日志」页查看应用运行日志。
把应用日志(stdout)同时捕获到进程内环形缓冲(deque),提供级别/关键字/条数
过滤查询缓冲在进程重启后清零;需要持久化的审计请另行落库
"""
from __future__ import annotations
import logging
from collections import deque
_BUFFER: deque[dict] = deque(maxlen=2000)
_LEVEL_ORDER = {"DEBUG": 10, "INFO": 20, "WARNING": 30, "ERROR": 40, "CRITICAL": 50}
class MemoryLogHandler(logging.Handler):
"""把日志记录写入内存环形缓冲。"""
def __init__(self) -> None:
super().__init__()
# format() 会在有 exc_info 时自动附带异常堆栈文本
self.setFormatter(logging.Formatter("%(message)s"))
def emit(self, record: logging.LogRecord) -> None:
try:
entry = {
"ts": record.created,
"level": record.levelname,
"logger": record.name,
"message": self.format(record),
}
_BUFFER.append(entry)
except Exception: # noqa: BLE001 日志采集绝不影响业务
self.handleError(record)
class _SQLNoiseFilter(logging.Filter):
"""过滤 SQLAlchemy 的 DEBUG/INFO 回显(只留警告以上)。"""
def filter(self, record: logging.LogRecord) -> bool:
return not (
record.name.startswith("sqlalchemy.") and record.levelno < logging.WARNING
)
def get_entries(
min_level: str | None = None,
keyword: str | None = None,
limit: int = 200,
) -> list[dict]:
"""按条件查询缓冲日志,最新在前。"""
min_no = _LEVEL_ORDER.get((min_level or "").upper(), 0)
kw = (keyword or "").strip().lower()
items = list(_BUFFER)
items.reverse()
out: list[dict] = []
for e in items:
if _LEVEL_ORDER.get(e["level"], 0) < min_no:
continue
if kw and kw not in e["message"].lower() and kw not in e["logger"].lower():
continue
out.append(e)
if len(out) >= limit:
break
return out
def setup_memory_logging(level: str = "INFO") -> None:
"""挂载内存 handler 到 root logger(幂等),并确保 root 级别不低于 INFO。"""
root = logging.getLogger()
if any(isinstance(h, MemoryLogHandler) for h in root.handlers):
return
handler = MemoryLogHandler()
handler.setLevel(logging.INFO)
handler.addFilter(_SQLNoiseFilter())
root.addHandler(handler)
if root.level == logging.NOTSET or root.level > logging.INFO:
root.setLevel(getattr(logging, level.upper(), logging.INFO))
-268
View File
@@ -1,268 +0,0 @@
"""运行时配置:数据库优先,回落 .env。
后台数据源页可在线修改的配置项存 app_settings ;
读取时 DB 有值用 DB,否则回落同名环境变量(pydantic settings)
DB 读取失败时也回落环境变量,保证采集不因管理表故障而中断
"""
from __future__ import annotations
import logging
import re
import secrets
from dataclasses import dataclass
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from src.core import crypto
from src.core.config import settings
from src.db.base import AsyncSessionLocal
from src.db.models import AppSetting
# 管理员密码哈希在 app_settings 中的键(不进 SETTING_DEFS 白名单:
# 只能走专门的改密接口 —— 需验证当前密码,不能被通用配置接口绕过)
ADMIN_PASSWORD_HASH_KEY = "ADMIN_PASSWORD_HASH"
# .env 明文密码的键名(仅作为初始值;后台改密后以哈希为准)
_ADMIN_ENV_KEY = "ADMIN_PASSWORD"
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class SettingDef:
key: str
label: str
description: str
sensitive: bool
# 允许在后台查看/修改的配置项白名单(之外的 key 一律拒绝读写)
SETTING_DEFS: dict[str, SettingDef] = {
"BZZOIRO_KEY": SettingDef(
"BZZOIRO_KEY", "Bzzoiro API Key",
"比赛赛程/比分/积分榜/统计的数据源凭证。支持多 key 轮换:用逗号、分号或换行分隔多个 key,遇到限流(429)自动切换",
sensitive=True,
),
"BZZOIRO_BASE": SettingDef(
"BZZOIRO_BASE", "Bzzoiro API 地址", "Bzzoiro 接口基础地址", sensitive=False,
),
"LLM_API_KEY": SettingDef(
"LLM_API_KEY", "LLM API Key", "大模型服务凭证(OpenAI 兼容接口)", sensitive=True,
),
"LLM_BASE_URL": SettingDef(
"LLM_BASE_URL", "LLM 接口地址", "如 https://api.deepseek.com/v1", sensitive=False,
),
"LLM_MODEL": SettingDef(
"LLM_MODEL", "LLM 模型", "如 deepseek-chat / gpt-4o", sensitive=False,
),
}
# ── 按角色独立配置 LLM 的键(5 专家 + 终裁) ──
# 每个角色可独立覆盖 模型 / 接口地址 / API Key;留空继承分层默认(见 orchestrator._agent_provider)。
AGENT_META: list[dict] = [
{"id": "form", "label": "近期状态分析专家"},
{"id": "stats", "label": "攻防数据分析专家"},
{"id": "home_away", "label": "主客因素分析专家"},
{"id": "standings", "label": "联赛排名分析专家"},
{"id": "h2h", "label": "历史交锋分析专家"},
{"id": "aggregator", "label": "终裁分析专家"},
]
for _agent in AGENT_META:
_u = _agent["id"].upper()
SETTING_DEFS[f"AGENT_{_u}_MODEL"] = SettingDef(
f"AGENT_{_u}_MODEL", f"{_agent['label']} 模型", "留空继承默认(专家层/全局)", sensitive=False,
)
SETTING_DEFS[f"AGENT_{_u}_BASE_URL"] = SettingDef(
f"AGENT_{_u}_BASE_URL", f"{_agent['label']} 接口地址", "留空继承全局 LLM_BASE_URL", sensitive=False,
)
SETTING_DEFS[f"AGENT_{_u}_API_KEY"] = SettingDef(
f"AGENT_{_u}_API_KEY", f"{_agent['label']} API Key", "留空继承全局 LLM_API_KEY", sensitive=True,
)
def mask_value(value: str, sensitive: bool) -> str:
"""脱敏展示:敏感值只留末 4 位;非敏感值原样返回。
key(逗号/分号/换行分隔)时显示数量, "3 个 key(末段 …XXXX)"
"""
if not value:
return ""
if not sensitive:
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 "****"
async def get_runtime_value(key: str) -> str:
"""读运行时配置:DB 覆盖值 → .env 默认值 → 空串。
敏感项入库时是密文,读出后自动解密;旧明文(迁移前) decrypt_value 透传
"""
defn = SETTING_DEFS.get(key)
try:
async with AsyncSessionLocal() as db:
row = await db.get(AppSetting, key)
if row and row.value:
value = crypto.decrypt_value(row.value) if defn and defn.sensitive else row.value
if value:
return value
except Exception:
logger.warning("读取运行时配置 %s 失败,回落环境变量", key)
return getattr(settings, key, "") or ""
async def set_runtime_value(key: str, value: str) -> None:
"""写入/更新 DB 覆盖值(调用方需先校验 key 在白名单内)。
敏感项(SETTING_DEFS.sensitive) Fernet 加密存储,库里不落明文
"""
defn = SETTING_DEFS.get(key)
stored = crypto.encrypt_value(value) if defn and defn.sensitive else value
async with AsyncSessionLocal() as db:
stmt = pg_insert(AppSetting).values(key=key, value=stored)
stmt = stmt.on_conflict_do_update(index_elements=["key"], set_={"value": stored})
await db.execute(stmt)
await db.commit()
logger.info("运行时配置 %s 已更新", key)
async def clear_runtime_value(key: str) -> None:
"""删除 DB 覆盖值,回落 .env(调用方需先校验 key 在白名单内)。"""
async with AsyncSessionLocal() as db:
row = await db.get(AppSetting, key)
if row is not None:
await db.delete(row)
await db.commit()
logger.info("运行时配置 %s 已清除覆盖", key)
async def get_setting_origin(key: str) -> tuple[str, str]:
"""返回 (origin, 当前生效值)。origin ∈ db / env / none。"""
defn = SETTING_DEFS.get(key)
try:
async with AsyncSessionLocal() as db:
row = await db.get(AppSetting, key)
if row and row.value:
value = crypto.decrypt_value(row.value) if defn and defn.sensitive else row.value
return "db", value
except Exception:
logger.warning("读取运行时配置 %s 来源失败,按环境变量处理", key)
env_value = getattr(settings, key, "") or ""
return ("env", env_value) if env_value else ("none", "")
async def migrate_plaintext_sensitive_settings() -> int:
"""一次性迁移:把库中仍是明文的敏感项加密(幂等,启动时执行)。
返回加密的条数
"""
migrated = 0
sensitive_keys = {k for k, d in SETTING_DEFS.items() if d.sensitive}
async with AsyncSessionLocal() as db:
rows = (await db.execute(select(AppSetting))).scalars().all()
for row in rows:
if row.key not in sensitive_keys or crypto.is_encrypted(row.value):
continue
row.value = crypto.encrypt_value(row.value)
migrated += 1
await db.commit()
if migrated:
logger.info("已加密迁移 %d 条明文敏感配置", migrated)
return migrated
# ── 管理员密码:只存 scrypt 哈希,永不存明文 ──────────────────────
async def get_admin_password_hash() -> str:
"""库中管理员密码哈希;无则空串。"""
try:
async with AsyncSessionLocal() as db:
row = await db.get(AppSetting, ADMIN_PASSWORD_HASH_KEY)
return row.value if row else ""
except Exception:
logger.warning("读取管理员密码哈希失败")
return ""
async def set_admin_password_hash(hash_str: str) -> None:
async with AsyncSessionLocal() as db:
stmt = pg_insert(AppSetting).values(key=ADMIN_PASSWORD_HASH_KEY, value=hash_str)
stmt = stmt.on_conflict_do_update(index_elements=["key"], set_={"value": hash_str})
await db.execute(stmt)
await db.commit()
logger.info("管理员密码哈希已更新")
async def verify_admin_password(candidate: str) -> bool:
"""校验管理员密码:优先哈希;哈希不存在时回落 .env 明文(未迁移的旧部署)。"""
stored_hash = await get_admin_password_hash()
if stored_hash:
return crypto.verify_password(candidate, stored_hash)
env_pw = getattr(settings, _ADMIN_ENV_KEY, "") or ""
return bool(env_pw) and secrets.compare_digest(candidate, env_pw)
async def get_admin_credential_fingerprint() -> str:
"""管理员凭证指纹(作为会话签名密钥的输入)。
用密码哈希而非密码本身:凭证变化 指纹变化 全部会话失效
"""
stored_hash = await get_admin_password_hash()
if stored_hash:
return f"hash:{stored_hash}"
env_pw = getattr(settings, _ADMIN_ENV_KEY, "") or ""
return f"env:{env_pw}" if env_pw else ""
async def _get_raw_setting(key: str) -> str:
async with AsyncSessionLocal() as db:
row = await db.get(AppSetting, key)
return row.value if row else ""
async def _delete_setting(key: str) -> None:
async with AsyncSessionLocal() as db:
row = await db.get(AppSetting, key)
if row is not None:
await db.delete(row)
await db.commit()
async def ensure_admin_password_hashed() -> bool:
"""启动迁移:确保管理员密码只以 scrypt 哈希存在(幂等)。
迁移来源优先级:
1. 库中旧版明文 ADMIN_PASSWORD (旧代码写入的当前密码,迁移后删除该明文行)
2. .env ADMIN_PASSWORD 初始值
"""
if await get_admin_password_hash():
# 哈希已存在:清除旧版可能残留的明文行
if await _get_raw_setting(_ADMIN_ENV_KEY):
await _delete_setting(_ADMIN_ENV_KEY)
logger.info("已删除遗留的明文 ADMIN_PASSWORD 行(哈希已存在)")
return False
legacy_plain = await _get_raw_setting(_ADMIN_ENV_KEY)
if legacy_plain:
await set_admin_password_hash(crypto.hash_password(legacy_plain))
await _delete_setting(_ADMIN_ENV_KEY)
logger.info("已将库中明文管理员密码迁移为 scrypt 哈希,明文行已删除")
return True
env_pw = getattr(settings, _ADMIN_ENV_KEY, "") or ""
if not env_pw:
return False
await set_admin_password_hash(crypto.hash_password(env_pw))
logger.info(
"已将 .env 中的明文管理员密码迁移为 scrypt 哈希。"
"建议现在从 .env 中删除 ADMIN_PASSWORD 明文行。"
)
return True
-252
View File
@@ -1,252 +0,0 @@
"""定时任务调度器:支持 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()
-121
View File
@@ -1,121 +0,0 @@
"""生产环境启动安全校验:缺失关键配置则拒绝启动(生产)或警告(开发)。
校验项:
- SECRET_KEY 非空且非弱默认值
- 鉴权已配置(密码哈希 / .env 明文密码 / API Key 任一)
- DATABASE_URL 不使用示例弱密码(football:football)
deps.py fail-closed 互补:此处是启动时一次性校验 + 明确报错,
避免生产带着危险配置上线却只在被攻击时才暴露
"""
from __future__ import annotations
import logging
import sys
from src.core.config import settings
from src.core.runtime_config import get_admin_password_hash
logger = logging.getLogger(__name__)
# 明显的弱 SECRET_KEY 黑名单(大小写无关)
_WEAK_SECRET_KEYS = {
"", "changeme", "secret", "password", "123456", "admin",
"default", "dev", "development", "test", "example",
"openssl rand -base64 32", # 有人把生成指令直接粘进去
}
_MIN_SECRET_KEY_LEN = 16
# 示例弱数据库密码(仅识别最明显的;自定义强密码不受影响)
_WEAK_DB_PATTERNS = ("football:football@", "admin:admin@", "password@", "123456@")
class SecurityCheckError(Exception):
"""生产环境安全校验失败。"""
async def _auth_configured() -> bool:
"""运行时鉴权是否已配置(含数据库密码哈希/.env 明文/API Key)。"""
if await get_admin_password_hash():
return True
if settings.ADMIN_PASSWORD or settings.ADMIN_API_KEY:
return True
return False
def _check_secret_key() -> list[str]:
"""返回 SECRET_KEY 的问题列表(空=通过)。"""
problems: list[str] = []
key = settings.SECRET_KEY
if not key:
problems.append("SECRET_KEY 未设置,加密与会话签名无法保障")
return problems
if key.lower().strip() in _WEAK_SECRET_KEYS:
problems.append(f"SECRET_KEY 为弱默认值({key[:20]}...),请生成强随机值: openssl rand -base64 32")
elif len(key) < _MIN_SECRET_KEY_LEN:
problems.append(f"SECRET_KEY 过短({len(key)} 字符),建议至少 {_MIN_SECRET_KEY_LEN}")
return problems
def _check_database_url() -> list[str]:
problems: list[str] = []
url = settings.DATABASE_URL.lower()
for pat in _WEAK_DB_PATTERNS:
if pat in url:
problems.append(f"DATABASE_URL 使用示例弱密码({pat.rstrip('@')}),生产环境必须更换")
break
return problems
async def validate_security() -> dict:
"""执行安全校验。
返回 {"ok": bool, "errors": [...], "warnings": [...]}
errors 为阻断性问题,warnings 为建议
"""
errors: list[str] = []
warnings: list[str] = []
errors.extend(_check_secret_key())
if not await _auth_configured():
errors.append("管理鉴权未配置:请设置 ADMIN_PASSWORD 或 ADMIN_API_KEY")
warnings.extend(_check_database_url())
# 生产环境:DB 弱密码也升级为阻断
if settings.APP_ENV == "production" and warnings:
errors.extend(warnings)
warnings = []
ok = not errors
return {"ok": ok, "errors": errors, "warnings": warnings}
async def assert_security_on_startup() -> None:
"""启动入口:生产环境校验失败则拒绝启动,开发环境仅警告。"""
result = await validate_security()
for w in result["warnings"]:
logger.warning("[security-check] %s", w)
if result["ok"]:
if result["warnings"]:
logger.warning("[security-check] 存在 %d 项警告,建议修复", len(result["warnings"]))
else:
logger.info("[security-check] 安全校验通过")
return
# 阻断
is_prod = settings.APP_ENV == "production"
level = logging.ERROR if is_prod else logging.WARNING
for e in result["errors"]:
logger.log(level, "[security-check] %s", e)
if is_prod:
logger.critical(
"[security-check] 生产环境安全校验失败,拒绝启动。请修复上述 %d 项问题后重试。",
len(result["errors"]),
)
# 明确退出,避免带着危险配置上线
sys.exit(1)
logger.warning("[security-check] 开发环境存在 %d 项问题(未阻断),请尽快修复", len(result["errors"]))
+69 -476
View File
@@ -1,33 +1,25 @@
"""Bzzoiro 数据源:抓取 + 入库(单一数据源)
"""Bzzoiro 数据源:抓取 + 入库。
三条管线:
1. events 比赛日程/比分(/events/), source_event_id 血缘
2. standings 联赛积分榜快照(/leagues/{id}/standings/)
3. stats 已完赛比赛详细统计回填(/events/{id}/stats/)
使用 Repository 模式进行数据访问,不直接控制事务(由调用方 UnitOfWork 控制)
迁移自旧项目 app/data/sources/bzzoiro/,改成 async + 简化入库
使用 Repository 模式进行数据访问,不直接控制事务
"""
from __future__ import annotations
import asyncio
import json as _json
import logging
import random
from collections.abc import Iterable
from datetime import datetime, timedelta, timezone
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.orm import selectinload
import httpx
from src.core.runtime_config import get_runtime_value
from src.core.config import settings
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.key_ring import _mask, get_key_ring
from src.data.normalize import normalize_bzzoiro
from src.data.team_names_zh import zh_name
from src.data.sources import register
from src.db.models import League, Match, MatchStats, Standing, Team, RawEvent, IngestFailure, DataLineage
from src.db.models import League, Match, MatchStats, Team
logger = logging.getLogger(__name__)
@@ -41,16 +33,6 @@ def _to_date(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]:
"""比赛去重键:(主队, 客队, 天级日期 ISO 字符串)。
@@ -63,57 +45,32 @@ def _match_key(home_team_id: int, away_team_id: int, match_date) -> tuple[int, i
async def _fetch_json_async(path: str, params: dict | None = None, max_retries: int = 3) -> dict | list:
"""异步 HTTP(bzzoiro 使用 httpx,不再阻塞事件循环线程池)。
key 轮换:遇到 429 自动切换到下一个 key;全部 key 冷却时等待最早恢复
"""
base = (await get_runtime_value("BZZOIRO_BASE")).rstrip("/")
raw_keys = await get_runtime_value("BZZOIRO_KEY")
ring = get_key_ring(base, raw_keys)
"""异步 HTTP(bzzoiro 使用 httpx,不再阻塞事件循环线程池)。"""
base = settings.BZZOIRO_BASE.rstrip("/")
url = f"{base}/{path.lstrip('/')}"
key = ring.get()
key = settings.BZZOIRO_KEY
if not key:
raise RuntimeError("BZZOIRO_KEY 未设置")
headers = {
"Authorization": f"Token {key}",
"Accept": "application/json",
}
last_exc: Exception | None = None
for attempt in range(max_retries):
headers = {
"Authorization": f"Token {key}",
"Accept": "application/json",
}
try:
client = get_client()
# 整请求兜底: httpx 无 total 超时,用 wait_for 防「滴水式」限速挂死
resp = await asyncio.wait_for(
client.get(
url, headers=headers, params=params,
timeout=httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0),
),
timeout=60.0,
)
resp = await client.get(url, headers=headers, params=params, timeout=30)
resp.raise_for_status()
return resp.json()
except Exception as e:
last_exc = e
status = getattr(getattr(e, "response", None), "status_code", None)
if status == 429:
# 限流:标记当前 key 冷却,切换到下一个
new_key = ring.report_rate_limited(key)
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
delay = min(2 ** attempt, 16) + random.uniform(0, 1)
logger.warning("bzzoiro 429, retry %d in %.1fs", attempt + 1, delay)
await asyncio.sleep(delay)
continue
if 500 <= (status or 0) < 600:
delay = min(2 ** attempt, 16) + random.uniform(0, 1)
@@ -197,16 +154,8 @@ class BzzoiroSource:
try:
raw_events = await fetch_bzzoiro_events(code, status=status, date_from=date_from, date_to=date_to)
except Exception as e:
# 单联赛抓取失败隔离:记录错误后继续其余联赛,不拖垮整批
logger.exception("bzzoiro fetch failed for %s", code)
league_r["errors"].append(f"fetch failed: {e}")
await _safe_write_ingest_failure(
db,
entity_type="events",
source_record_id=None,
error=e,
raw_payload={"league": code, "status": status, "date_from": date_from, "date_to": date_to},
)
result["leagues"][code] = league_r
continue
@@ -221,7 +170,7 @@ class BzzoiroSource:
# === 批量优化: 预加载球队和已有比赛到内存 ===
team_name_to_id: dict[str, int] = {}
existing_matches: dict[tuple[int, int, str], Match] = {} # 完整对象,避免重复查询
# (NormalizedMatch, 原始 event) 成对保存:后续写 source_event_id 时
# (NormalizedMatch, 原始 event) 成对保存:后续写 source_record_id 时
# 必须用配对的那条 event,不能依赖外层循环变量残留值。
normalized_matches: list[tuple] = []
@@ -249,8 +198,8 @@ class BzzoiroSource:
# P1-2: 按需加载,只加载 raw_events 涉及日期范围的比赛(加 30 天缓冲)
# 避免加载联赛全部历史比赛到内存(多赛季采集时内存溢出)
if normalized_matches:
# normalized_matches 存的是 (nm, raw) 元组,遍历需解包
dates = [nm.date for nm, _raw in normalized_matches if nm.date is not None]
from datetime import timedelta
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)
@@ -270,7 +219,7 @@ class BzzoiroSource:
# 球队: 内存查找 + 按需创建
home_team_id = team_name_to_id.get(nm.home_team)
if home_team_id is None:
home = Team(name=nm.home_team, name_zh=zh_name(nm.home_team))
home = Team(name=nm.home_team)
db.add(home)
await db.flush()
home_team_id = home.id
@@ -278,7 +227,7 @@ class BzzoiroSource:
away_team_id = team_name_to_id.get(nm.away_team)
if away_team_id is None:
away = Team(name=nm.away_team, name_zh=zh_name(nm.away_team))
away = Team(name=nm.away_team)
db.add(away)
await db.flush()
away_team_id = away.id
@@ -302,13 +251,33 @@ class BzzoiroSource:
home_ht_goals=nm.home_ht_goals,
away_ht_goals=nm.away_ht_goals,
match_stage=nm.match_stage,
source_event_id=_to_int_or_none(raw.get("id")),
)
db.add(m)
await db.flush()
existing_matches[match_key] = m # 防止同批重复
# 统计字段不在 /events/ 载荷中(单独由 stats 管线回填),
# 此处不再创建 MatchStats。
if nm.home_xg is not None or nm.away_xg is not None:
now = datetime.now(timezone.utc)
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_event_id=str(raw.get("id", "")),
retrieved_at=now,
available_at=now,
)
db.add(stats)
league_r["inserted"] += 1
else:
# 已有比赛: 直接从内存获取对象更新(无需再查询)
@@ -325,11 +294,28 @@ class BzzoiroSource:
if existing_match.match_stage is None and nm.match_stage:
existing_match.match_stage = nm.match_stage
changed = True
if existing_match.source_event_id is None:
eid = _to_int_or_none(raw.get("id"))
if eid is not None:
existing_match.source_event_id = eid
changed = True
if existing_match.stats is None and (nm.home_xg is not None or nm.away_xg is not None):
now = datetime.now(timezone.utc)
existing_match.stats = MatchStats(
match_id=existing_match.id,
source="bzzoiro",
source_event_id=str(raw.get("id", "")),
retrieved_at=now,
available_at=now,
)
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:
league_r["updated"] += 1
@@ -338,396 +324,3 @@ class BzzoiroSource:
result["total_inserted"] += league_r["inserted"]
result["total_updated"] += league_r["updated"]
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 _safe_write_ingest_failure(
db,
*,
entity_type: str,
source_record_id: str | None,
error: Exception,
raw_payload: dict | None = None,
) -> None:
"""抓取失败时尽力写入死信表(失败不影响主流程)。
死信是可观测性基础设施, RawEvent/Lineage 同级:写入失败只记
warning,绝不能让原始抓取错误之外的新异常打断采集循环
"""
try:
await _write_ingest_failure(
db, "bzzoiro", entity_type, source_record_id,
"fetch_error", str(error), raw_payload,
)
except Exception:
logger.warning(
"写入 ingest_failures 死信失败(entity=%s, record=%s): %s",
entity_type, source_record_id, error, exc_info=True,
)
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, "errors": []}
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))
await _safe_write_ingest_failure(
db,
entity_type="standings",
source_record_id=None,
error=e,
raw_payload={"league": code, "season": season},
)
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 _safe_write_ingest_failure(
db,
entity_type="match_stats",
source_record_id=str(m.source_event_id),
error=e,
raw_payload={"match_id": m.id},
)
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
+11 -2
View File
@@ -1,4 +1,4 @@
"""数据源配置常量(联赛映射)。数据源统一为 bzzoiro(单一数据源)。"""
"""数据源配置常量(联赛映射)。"""
from __future__ import annotations
# fdco 风格代码 → bzzoiro league_id
@@ -12,6 +12,15 @@ BZZOIRO_LEAGUE_IDS: dict[str, int] = {
"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 代码 → 显示名
LEAGUE_NAMES: dict[str, str] = {
"E0": "Premier League",
@@ -34,4 +43,4 @@ LEAGUE_COUNTRIES: dict[str, str] = {
"EL": "Europe",
}
REQUEST_INTERVAL = 2.0 # bzzoiro 限速(秒);上游限速严厉时宁可慢一点
REQUEST_INTERVAL = 1.2 # bzzoiro 限速(秒)
+285
View File
@@ -0,0 +1,285 @@
"""伤停数据采集器(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 typing import Any
import httpx
from src.core.config import settings
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"
# P2-3: 缓存目录改用系统临时目录,避免源码树内写入
_CACHE_DIR = Path(tempfile.gettempdir()) / "profeto_injuries"
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 = settings.API_FOOTBALL_KEY
if not api_key:
raise RuntimeError("API_FOOTBALL_KEY 未设置")
cache_dir = _CACHE_DIR
cache_dir.mkdir(parents=True, exist_ok=True)
# 缓存命中 (7 天内有效)
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 < 168: # 7 天
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 client.get(url, headers=headers, params=params, timeout=30)
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 控制
P1-4: 批量幂等检查,避免逐条查询的竞态条件(并发采集时 IntegrityError)
"""
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import selectinload
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}
# P1-4: 收集所有待插入记录的键,批量查询已存在的记录
# 避免逐条查询 + 插入的竞态条件(两个并发请求同时通过检查 → IntegrityError)
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)
# 解析日期
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
# 强制 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,
})
except Exception as e:
result["errors"].append(f"parse error: {e}")
# P1-4: 批量查询已存在的记录(1 次 DB 往返)
existing_keys: set[tuple] = set()
if pending_records:
# 构造查询条件:所有 (player_id, fixture_id, injury_type) 组合
# 使用 OR 条件批量查询
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}
# P1-4: 批量插入(跳过已存在的)
for rec in pending_records:
key = (rec["player_id"], rec["fixture_id"], rec["injury_type"])
if key in existing_keys:
continue
injury = Injury(**rec)
db.add(injury)
result["inserted"] += 1
# 每 50 条 flush 一次,减少内存压力,同时捕获 IntegrityError
if result["inserted"] % 50 == 0:
try:
await db.flush()
except IntegrityError:
# P1-4: 并发采集时可能仍有竞态,回退到逐条插入
await db.rollback()
logger.warning("injuries batch IntegrityError, falling back to per-record insert")
return await _ingest_injuries_fallback(db, pending_records, result)
# 最终 flush
try:
await db.flush()
except IntegrityError:
await db.rollback()
logger.warning("injuries final flush IntegrityError, falling back to per-record insert")
return await _ingest_injuries_fallback(db, pending_records, result)
# 注意: 不在此处 commit,由调用方 UnitOfWork 控制事务
logger.info("injuries: fetched %d, inserted %d for %s", result["count"], result["inserted"], date)
return result
async def _ingest_injuries_fallback(db, pending_records: list[dict], result: dict) -> dict:
"""P1-4: 逐条插入回退,捕获每条 IntegrityError 避免整批回滚。"""
from sqlalchemy.exc import IntegrityError
from src.db.models import Injury
inserted = 0
for rec in pending_records:
injury = Injury(**rec)
db.add(injury)
try:
await db.flush()
inserted += 1
except IntegrityError:
await db.rollback()
# 已存在或其他冲突,跳过
continue
result["inserted"] = inserted
logger.info("injuries fallback: inserted %d records", inserted)
return result
async def get_injuries_for_match(db, team_id: int, match_date, as_of=None) -> list[Injury]:
"""查询某场比赛前某队的伤停名单(比赛日仍缺阵的)。
Args:
db: 数据库 session
team_id: 球队 ID
match_date: 比赛日期
as_of: 数据截止时间(用于回测防泄漏)
Returns:
伤停记录列表
"""
from sqlalchemy import select
from src.db.models import Injury
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:
if hasattr(as_of, "date") and callable(as_of.date):
as_of = as_of.date()
stmt = stmt.where(Injury.retrieved_at <= as_of)
result = await db.execute(stmt)
return list(result.scalars().all())
-145
View File
@@ -1,145 +0,0 @@
"""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
+34 -41
View File
@@ -18,7 +18,7 @@ VALID_STATUS = {"finished", "scheduled", "in_play", "paused", "postponed", "canc
STATUS_MAP = {
"finished": "finished", "completed": "finished", "done": "finished", "awarded": "finished",
"scheduled": "scheduled", "upcoming": "scheduled", "notstarted": "scheduled", "not_started": "scheduled",
"scheduled": "scheduled", "upcoming": "scheduled",
"in_play": "in_play", "live": "in_play",
"paused": "paused", "postponed": "postponed",
"cancelled": "cancelled", "canceled": "cancelled", "abandoned": "cancelled",
@@ -145,23 +145,7 @@ def _to_float(v) -> float | None:
def normalize_bzzoiro(raw: dict, league_type: str) -> NormalizedMatch | None:
"""bzzoiro event → NormalizedMatch。
统计字段映射说明:
当前字段名基于常见足球 API 模式推测(home_shots/away_shots ),
未经真实 bzzoiro 响应校验若真实字段不同,映射结果将为 None
待用真实响应核对的字段清单(请提供一份 event 样例验证):
- 射门: home_shots / away_shots( shots_home / shots_away)
- 射正: home_shots_on_target / away_shots_on_target( sot_home / sot_away)
- 角球: home_corners / away_corners( corners_home / corners_away)
- 控球: home_possession( possession,仅主队值)
- xG: home_xg / away_xg( xg_home / xg_away / expected_goals_home / expected_goals_away)
- 黄牌: home_yellow_cards / away_yellow_cards( yellow_cards_home / yellow_cards_away)
- 红牌: home_red_cards / away_red_cards( red_cards_home / red_cards_away)
映射策略:优先查主字段名,回退到别名所有字段缺失时保持 None,不伪造
"""
"""bzzoiro event → NormalizedMatch。"""
from src.data.team_names import normalize as normalize_name
date = _parse_date(raw.get("event_date"))
@@ -193,29 +177,38 @@ def normalize_bzzoiro(raw: dict, league_type: str) -> NormalizedMatch | None:
m.match_stage = _rn_name
elif _rn:
m.match_stage = f"{_rn}"
# 统计字段映射(API 字段名 → NormalizedMatch)
# API 可能提供的字段:home_shots/away_shots, shots_on_target, corners, possession, xg, cards
# API 没有的字段保持 None,不伪造
m.home_shots = _to_int(raw.get("home_shots", raw.get("shots_home")))
m.away_shots = _to_int(raw.get("away_shots", raw.get("shots_away")))
m.home_shots_on_target = _to_int(raw.get("home_shots_on_target", raw.get("sot_home")))
m.away_shots_on_target = _to_int(raw.get("away_shots_on_target", raw.get("sot_away")))
m.home_corners = _to_int(raw.get("home_corners", raw.get("corners_home")))
m.away_corners = _to_int(raw.get("away_corners", raw.get("corners_away")))
# 控球率:API 通常只给 home 值,away = 100 - home
possession_home = _to_float(raw.get("home_possession", raw.get("possession")))
if possession_home is not None:
m.home_possession = possession_home
# xG
m.home_xg = _to_float(raw.get("home_xg", raw.get("xg_home", raw.get("expected_goals_home"))))
m.away_xg = _to_float(raw.get("away_xg", raw.get("xg_away", raw.get("expected_goals_away"))))
# 牌
m.home_yellow_cards = _to_int(raw.get("home_yellow_cards", raw.get("yellow_cards_home")))
m.away_yellow_cards = _to_int(raw.get("away_yellow_cards", raw.get("yellow_cards_away")))
m.home_red_cards = _to_int(raw.get("home_red_cards", raw.get("red_cards_home")))
m.away_red_cards = _to_int(raw.get("away_red_cards", raw.get("red_cards_away")))
if m.match_status == "finished" and m.home_goals is None:
m.match_status = "scheduled"
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 = raw.get("xG", {}).get("h") if isinstance(raw.get("xG"), dict) else None
away_xg = raw.get("xG", {}).get("a") if isinstance(raw.get("xG"), dict) else None
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,
)
+16 -42
View File
@@ -3,18 +3,14 @@
定义 DataSource 契约,并提供全局注册表供路由层分发
每个比赛数据源实现该协议,注册后即可通过统一入口调度
当前只有 bzzoiro 一个数据源(Understat / injuries 已移除),
保留协议与注册表是为了统一 ingest 调度入口的结构
: injuries 是球员级独立领域( Injury ),不遵循此协议
"""
from __future__ import annotations
import logging
from typing import Protocol
from src.db.base import AsyncSession
logger = logging.getLogger(__name__)
class DataSource(Protocol):
"""比赛数据源契约:抓取 → 规范化 → 入库。"""
@@ -32,23 +28,17 @@ class DataSource(Protocol):
# ── 注册表 ──
_SOURCES: dict[str, DataSource] = {}
# 延迟加载标记:保证 _load_sources() 最多执行一次,重复调用廉价
_loaded = False
def register(source):
"""装饰器:将数据源注册到全局注册表。
兼容类注册与实例注册:类会被实例化后存入(保证 get_source 返回实例)
"""
obj = source() if isinstance(source, type) else source
_SOURCES[obj.name] = obj
def register(source: DataSource) -> DataSource:
"""装饰器:将数据源注册到全局注册表。"""
_SOURCES[source.name] = source
return source
def get_source(name: str) -> DataSource:
"""按名获取数据源。"""
_load_sources()
if not _SOURCES:
_load_sources()
if name not in _SOURCES:
raise ValueError(f"未知数据源: {name}")
return _SOURCES[name]
@@ -56,35 +46,19 @@ def get_source(name: str) -> DataSource:
def list_sources() -> list[str]:
"""列出所有已注册数据源名。"""
_load_sources()
if not _SOURCES:
_load_sources()
return list(_SOURCES.keys())
def _load_sources() -> None:
"""延迟导入数据源触发 @register(避免循环导入)。
导入失败必须留下痕迹:静默吞掉 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
"""延迟导入数据源触发 @register(避免循环导入)。"""
from src.data.bzzoiro import BzzoiroSource # noqa: F811
from src.data.understat import UnderstatSource # noqa: F811
# 模块加载时预热(失败会记录日志,不再静默)
_load_sources()
# 保持向后兼容:模块加载时尝试加载(但不再强制)
try:
_load_sources()
except Exception:
pass
+2 -28
View File
@@ -1,9 +1,6 @@
"""队名归一化:各源队名 → 统一规范名。
迁移自旧项目 app/data/team_names.py
NFKD 归一化会剥离变音符号(üu),导致Bayern MünchenBayern Munich
映射到不同规范名修复:双重查找(原始名 + NFKD ) + 补齐常见变体键
"""
from __future__ import annotations
@@ -64,15 +61,10 @@ NORMALIZE_MAP = {
"Barcelona": "Barcelona",
# ---- 德甲 ----
"Bayern Munich": "Bayern München",
"Bayern Munchen": "Bayern München", # NFKD stripped variant
"Bayern München": "Bayern München", # canonical with umlaut (direct hit)
"FC Koln": "FC Köln",
"FC Köln": "FC Köln",
"RB Leipzig": "RB Leipzig",
"Borussia Dortmund": "Borussia Dortmund",
"Borussia M'gladbach": "Borussia Mönchengladbach",
"Borussia Monchengladbach": "Borussia Mönchengladbach", # NFKD stripped
"Borussia Mönchengladbach": "Borussia Mönchengladbach", # canonical
"Bayer Leverkusen": "Bayer Leverkusen",
"Eintracht Frankfurt": "Eintracht Frankfurt",
"VfB Stuttgart": "VfB Stuttgart",
@@ -129,9 +121,6 @@ NORMALIZE_MAP = {
"Le Havre": "Le Havre AC",
"Lorient": "FC Lorient",
"Saint-Etienne": "AS Saint-Étienne",
"Saint-Etienne": "AS Saint-Étienne", # NFKD stripped (ê → e)
"AS Saint-Étienne": "AS Saint-Étienne", # canonical prefix
"AS Saint-Etienne": "AS Saint-Étienne", # NFKD stripped with prefix
"Angers": "Angers SCO",
"Auxerre": "AJ Auxerre",
"Leganes": "Leganés",
@@ -139,25 +128,10 @@ NORMALIZE_MAP = {
def normalize(name: str) -> str:
"""队名归一化:变音符号变体 → 统一规范名。
双重查找策略:
1. 原始名(带变音)直接查表
2. NFKD 去变音后再查表
确保Bayern MünchenBayern MunchenBayern Munich
都映射到同一规范名Bayern München
"""
if not name:
return ""
# 1. 原始名直接查表(保留变音变体)
stripped = name.strip()
if stripped in NORMALIZE_MAP:
return NORMALIZE_MAP[stripped]
# 2. NFKD 去变音后查表
n = unicodedata.normalize("NFKD", stripped)
# unicode 归一(重音)
n = unicodedata.normalize("NFKD", name)
n = "".join(c for c in n if not unicodedata.combining(c))
n = n.strip()
return NORMALIZE_MAP.get(n, n)
-176
View File
@@ -1,176 +0,0 @@
"""球队中文译名表: 规范化英文名 → 中文。
来源: 手工整理(五大联赛全部 + 欧战常客)
未收录的球队保持英文显示(前端回落),新队采集入库时自动查此表
"""
TEAM_NAME_ZH: dict[str, str] = {
# ── 英格兰 ──
"Arsenal": "阿森纳", "Aston Villa": "阿斯顿维拉", "Chelsea": "切尔西",
"Liverpool FC": "利物浦", "Liverpool": "利物浦",
"Manchester City": "曼城", "Manchester United": "曼联",
"Tottenham Hotspur": "托特纳姆热刺", "Newcastle United": "纽卡斯尔联",
"West Ham United": "西汉姆联", "Everton": "埃弗顿", "Fulham": "富勒姆",
"Crystal Palace": "水晶宫", "Brentford": "布伦特福德",
"Brighton & Hove Albion": "布莱顿", "Brighton and Hove Albion": "布莱顿",
"Wolverhampton": "狼队", "Wolverhampton Wanderers": "狼队",
"Nottingham Forest": "诺丁汉森林", "AFC Bournemouth": "伯恩茅斯",
"Leeds United": "利兹联", "Leicester City": "莱斯特城",
"Ipswich Town": "伊普斯维奇", "Southampton": "南安普顿",
"Norwich City": "诺维奇", "Norwich": "诺维奇",
"Sheffield United": "谢菲尔德联",
"Sheffield Wednesday": "谢周三", "Stoke City": "斯托克城",
"Sunderland": "桑德兰", "Burnley": "伯恩利", "Watford": "沃特福德",
"Hull City": "赫尔城", "Huddersfield Town": "哈德斯菲尔德",
"Luton Town": "卢顿", "Cardiff City": "加的夫", "Swansea City": "斯旺西",
"West Bromwich Albion": "西布罗姆维奇", "Birmingham City": "伯明翰",
"Blackburn Rovers": "布莱克本", "Bolton Wanderers": "博尔顿",
"Barnsley": "巴恩斯利", "Blackpool": "布莱克浦",
"Bradford City": "布拉德福德", "Charlton Athletic": "查尔顿竞技",
"Coventry City": "考文垂", "Derby County": "德比郡",
"Middlesbrough": "米德尔斯堡", "Milton Keynes Dons": "米尔顿凯恩斯",
"Oldham Athletic": "奥尔德姆竞技", "Portsmouth": "朴茨茅斯",
"Queens Park Rangers": "女王公园巡游者", "Reading": "雷丁",
"Swindon Town": "斯温登", "Wigan Athletic": "维冈竞技",
# ── 苏格兰/爱尔兰 ──
"Celtic": "凯尔特人", "Rangers": "流浪者", "Aberdeen": "阿伯丁",
"Heart of Midlothian": "哈茨", "Hibernian": "希伯尼安",
"Derry City": "德里城", "Shelbourne": "谢尔本", "Larne FC": "拉恩",
"Linfield FC": "连菲尔德", "Shamrock Rovers": "沙姆罗克流浪",
# ── 西班牙 ──
"Real Madrid": "皇家马德里", "FC Barcelona": "巴塞罗那",
"Atlético Madrid": "马德里竞技", "Atletico Madrid": "马德里竞技",
"Athletic Club": "毕尔巴鄂竞技",
"Real Sociedad": "皇家社会", "Villarreal": "比利亚雷亚尔",
"Real Betis": "皇家贝蒂斯", "Sevilla": "塞维利亚", "Valencia": "瓦伦西亚",
"Celta Vigo": "塞尔塔", "Osasuna": "奥萨苏纳", "Getafe": "赫塔菲",
"Rayo Vallecano": "巴列卡诺", "Mallorca": "马略卡", "Girona FC": "赫罗纳",
"Girona": "赫罗纳", "Espanyol": "西班牙人", "UD Las Palmas": "拉斯帕尔马斯",
"Las Palmas": "拉斯帕尔马斯", "Deportivo Alavés": "阿拉维斯",
"Leganés": "莱加内斯", "Leganes": "莱加内斯",
"Elche": "埃尔切", "Levante UD": "莱万特",
"Malaga CF": "马拉加", "Deportivo de A Coruna": "拉科鲁尼亚",
"Real Oviedo": "皇家奥维耶多", "Real Racing Club": "桑坦德竞技",
"Real Valladolid": "巴利亚多利德",
# ── 意大利 ──
"Juventus": "尤文图斯", "AC Milan": "AC米兰", "Inter Milan": "国际米兰",
"SSC Napoli": "那不勒斯", "AS Roma": "罗马", "Lazio": "拉齐奥",
"Atalanta": "亚特兰大", "ACF Fiorentina": "佛罗伦萨", "Bologna": "博洛尼亚",
"Torino": "都灵", "Udinese": "乌迪内斯", "Genoa": "热那亚",
"Cagliari": "卡利亚里", "Hellas Verona": "维罗纳", "Lecce": "莱切",
"Empoli": "恩波利", "Parma": "帕尔马", "Como": "科莫", "Venezia": "威尼斯",
"Pisa": "比萨", "Cremonese": "克雷莫纳", "AC Monza": "蒙扎",
"Frosinone": "弗罗西诺内", "Sassuolo": "萨索洛",
# ── 德国 ──
"FC Bayern Munchen": "拜仁慕尼黑", "Bayern München": "拜仁慕尼黑",
"Borussia Dortmund": "多特蒙德",
"Bayer 04 Leverkusen": "勒沃库森", "RB Leipzig": "莱比锡红牛",
"Borussia Mönchengladbach": "门兴格拉德巴赫", "VfB Stuttgart": "斯图加特",
"Eintracht Frankfurt": "法兰克福", "VfL Wolfsburg": "沃尔夫斯堡",
"SC Freiburg": "弗赖堡", "TSG Hoffenheim": "霍芬海姆",
"1. FC Union Berlin": "柏林联合", "Union Berlin": "柏林联合",
"1. FC Koln": "科隆", "FC Köln": "科隆",
"1. FSV Mainz 05": "美因茨", "Mainz 05": "美因茨",
"FC Augsburg": "奥格斯堡",
"SV Werder Bremen": "云达不来梅", "Werder Bremen": "云达不来梅",
"VfL Bochum 1848": "波鸿", "VfL Bochum": "波鸿",
"1. FC Heidenheim": "海登海姆", "FC St. Pauli": "圣保利",
"Holstein Kiel": "荷尔斯泰因基尔", "FC Schalke 04": "沙尔克04",
"Hamburger SV": "汉堡", "SC Paderborn 07": "帕德博恩",
"SV 07 Elversberg": "埃弗斯贝格",
# ── 法国 ──
"Paris Saint-Germain": "巴黎圣日耳曼", "Olympique de Marseille": "马赛",
"Olympique Marseille": "马赛",
"Olympique Lyonnais": "里昂", "AS Monaco": "摩纳哥", "Lille OSC": "里尔",
"OGC Nice": "尼斯", "RC Lens": "朗斯", "Stade Rennais": "雷恩",
"RC Strasbourg": "斯特拉斯堡", "Stade Brestois": "布雷斯特",
"Stade de Reims": "兰斯", "FC Nantes": "南特", "Toulouse FC": "图卢兹",
"Montpellier HSC": "蒙彼利埃", "AS Saint-Étienne": "圣埃蒂安",
"AJ Auxerre": "欧塞尔", "Le Havre AC": "勒阿弗尔", "FC Lorient": "洛里昂",
"Metz": "梅斯", "Angers SCO": "昂热", "Guingamp": "甘冈", "Troyes": "特鲁瓦",
"Le Mans": "勒芒", "Paris FC": "巴黎FC", "USL Dunkerque": "敦刻尔克",
"Rodez AF": "罗德兹", "Red Star FC": "巴黎红星",
# ── 荷兰/比利时 ──
"AFC Ajax": "阿贾克斯", "PSV Eindhoven": "埃因霍温", "Feyenoord": "费耶诺德",
"AZ Alkmaar": "阿尔克马尔", "FC Twente": "特温特", "FC Utrecht": "乌得勒支",
"NEC Nijmegen": "奈梅亨", "Go Ahead Eagles": "前进之鹰",
"Club Brugge KV": "布鲁日", "RSC Anderlecht": "安德莱赫特",
"KRC Genk": "亨克", "Royale Union Saint-Gilloise": "圣吉罗斯联合",
"Sint-Truidense VV": "圣特鲁伊登",
# ── 葡萄牙 ──
"FC Porto": "波尔图", "Benfica": "本菲卡", "Sporting CP": "里斯本竞技",
"Sporting Braga": "布拉加", "Torreense": "托雷恩塞",
# ── 土耳其 ──
"Galatasaray": "加拉塔萨雷", "Fenerbahce": "费内巴切",
"Besiktas JK": "贝西克塔斯", "Trabzonspor": "特拉布宗体育",
"Samsunspor": "萨姆松体育",
# ── 北欧 ──
"Bodø/Glimt": "博多闪耀", "Viking FK": "维京", "Tromsø IL": "特罗姆瑟",
"SK Brann": "布兰", "Lillestrøm SK": "利勒斯特罗姆",
"Malmo FF": "马尔默", "IF Elfsborg": "埃尔夫斯堡", "BK Hacken": "哈肯",
"Hammarby IF": "哈马比", "Fredrikstad FK": "腓特烈斯塔",
"Mjallby AIF": "米亚尔比", "AGF": "奥胡斯", "FC Midtjylland": "中日德兰",
"FC København": "哥本哈根", "Klaksvikar Itrottarfelag": "克拉克斯维克",
"Vikingur Gøta": "戈塔维京人", "Vikingur Reykjavik": "雷克雅未克维京人",
"Breidablik Kopavogur": "布雷达布利克", "IF Vestri": "韦斯特里",
"Kuopion Palloseura": "库奥皮奥", "Ilves": "伊尔维斯",
# ── 瑞士/奥地利 ──
"Basel": "巴塞尔", "BSC Young Boys": "伯尔尼年轻人", "FC Lugano": "卢加诺",
"Servette FC": "塞尔维特", "FC Thun": "图恩",
"FC St. Gallen 1879": "圣加仑", "LASK": "林茨", "SK Sturm Graz": "格拉茨风暴",
"Red Bull Salzburg": "萨尔茨堡红牛", "Wolfsberger AC": "沃尔夫斯贝格",
# ── 中东欧 ──
"Shakhtar Donetsk": "顿涅茨克矿工", "Dynamo Kyiv": "基辅迪纳摩",
"Dinamo Minsk": "明斯克迪纳摩", "ML Vitebsk": "维捷布斯克",
"Legia Warszawa": "华沙莱吉亚", "Lech Poznan": "波兹南莱赫",
"Jagiellonia Białystok": "比亚韦斯托克亚盖隆尼亚",
"Gornik Zabrze": "扎布热矿工", "MSK Zilina": "日利纳",
"SK Slovan Bratislava": "布拉迪斯拉发斯洛万",
"FC Spartak Trnava": "特尔纳瓦斯巴达", "SK Slavia Praha": "布拉格斯拉维亚",
"AC Sparta Praha": "布拉格斯巴达", "FC Viktoria Plzen": "比尔森胜利",
"SK Sigma Olomouc": "奥洛莫茨西格玛", "FC Hradec Kralove": "赫拉德茨克拉洛韦",
"Banik Ostrava": "俄斯特拉发矿工", "Ferencvaros TC": "费伦茨瓦罗斯",
"Paksi FC": "帕克斯", "ETO FC Gyor": "杰尔", "CFR 1907 Cluj": "克卢日",
"FCSB": "布加勒斯特星", "FC Universitatea Cluj": "克卢日大学",
"Universitatea Craiova": "克拉约瓦大学", "Ludogorets": "卢多戈雷茨",
"Levski Sofia": "索非亚列夫斯基", "CSKA Sofia": "索非亚中央陆军",
"GNK Dinamo Zagreb": "萨格勒布迪纳摩", "HNK Hajduk Split": "斯普利特海杜克",
"HNK Rijeka": "里耶卡", "NK Olimpija Ljubljana": "卢布尔雅那奥林匹亚",
"NK Celje": "采列", "NK Aluminij Kidricevo": "阿卢米尼",
"FK Partizan": "贝尔格莱德游击队", "FK Vojvodina": "伏伊伏丁那",
"FK Crvena Zvezda": "贝尔格莱德红星", "FK Crvena zvezda": "贝尔格莱德红星",
"FK Borac Banja Luka": "巴尼亚卢卡战士", "HSK Zrinjski Mostar": "莫斯塔尔兹林斯基",
"FK Buducnost Podgorica": "波德戈里察未来",
"FK Sutjeska Niksic": "苏捷斯卡", "Sheriff Tiraspol": "谢里夫",
"FC Petrocub Hincesti": "佩特罗库布", "FC Milsami Orhei": "米尔萨米",
# ── 希腊/塞浦路斯/以色列 ──
"Olympiacos FC": "奥林匹亚科斯", "Panathinaikos FC": "帕纳辛纳科斯",
"PAOK": "塞萨洛尼基PAOK", "AEK Athens": "雅典AEK", "OFI Crete": "克里特OFI",
"Omonia Nicosia": "尼科西亚奥莫尼亚", "AEK Larnaca": "拉纳卡AEK",
"Pafos FC": "帕福斯", "Hapoel Be'er Sheva": "贝尔谢巴夏普尔",
"Maccabi Tel Aviv": "特拉维夫马卡比",
# ── 东南欧/高加索/中亚 ──
"Qarabag FK": "卡拉巴赫", "Sabah FK": "萨巴赫",
"FC Ararat-Armenia": "亚美尼亚阿拉拉特", "FC Noah": "诺亚",
"FK Aktobe": "阿克托别", "Kairat Almaty": "阿拉木图凯拉特",
"FC Kairat Almaty": "阿拉木图凯拉特",
"FK Vardar Skopje": "瓦尔达尔斯科普里", "KF Shkendija": "什肯迪贾",
"KF Egnatia": "埃格纳蒂亚", "FK Zalgiris": "萨尔吉里斯",
"FK Kauno Zalgiris": "考那斯萨尔吉里斯", "Riga FC": "里加",
"RFS": "里加足球学校", "FCI Levadia Tallinn": "塔林列瓦迪亚",
"Flora Tallinn": "塔林弗洛拉",
# ── 小联赛/外围 ──
"The New Saints": "新圣徒", "Lincoln Red Imps": "林肯红魔",
"Ħamrun Spartans FC": "哈姆伦斯巴达", "Floriana FC": "弗洛里亚纳",
"SP Tre Fiori": "特雷菲奥里", "SS Virtus": "维尔图斯",
"Inter Club d'Escaldes": "埃斯卡尔德斯", "Differdange FC 03": "迪费尔当",
"Atert Bissen": "比森", "FC Drita": "德里塔", "FC Prishtina": "普里什蒂纳",
"FC Iberia 1999": "伊比利亚1999", "FK Buducnost": "波德戈里察未来",
}
def zh_name(name: str | None) -> str | None:
"""查中文译名;未收录返回 None(由调用方回落英文)。"""
if not name:
return None
return TEAM_NAME_ZH.get(name.strip())
+204
View File
@@ -0,0 +1,204 @@
"""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
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 client.get(url, headers=headers, timeout=30)
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}")
# understat 返回 JS 对象,需要提取 JSON
text = resp.text
# 匹配 var datesData = JSON.parse('...');
match = re.search(r"var\s+datesData\s*=\s*JSON\.parse\('([^']+)'\)", text)
if not match:
logger.warning("understat 响应格式不符: %s...", text[:200])
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)
existing.stats = MatchStats(
match_id=existing.id,
source="understat",
source_event_id=str(raw.get("id", "")),
retrieved_at=now,
available_at=now,
)
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
-33
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
@@ -55,38 +54,6 @@ async def get_db_read() -> AsyncIterator[AsyncSession]:
await session.close()
@asynccontextmanager
async def short_read():
"""短生命周期 read session: 用于非路由上下文(如后台任务、手动调用)。
用法:
async with short_read() as session:
m = await session.get(Match, match_id)
# session 已关闭,连接已释放
"""
async with AsyncSessionLocal() as session:
yield session
@asynccontextmanager
async def short_write():
"""短生命周期 write session: 提交后立即释放。
用法:
async with short_write() as session:
session.add(pred)
await session.commit()
# session 已关闭,连接已释放
"""
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
async def init_db() -> None:
"""验证数据库连接(不建表)。
+22 -208
View File
@@ -1,13 +1,9 @@
"""ORM 模型: leagues / teams / matches / match_stats / standings / predictions。
数据源统一为 bzzoiro(单一数据源),伤停(injuries) Understat 已移除
"""
"""6 张表 ORM: leagues / teams / matches / match_stats / predictions / injuries。"""
from __future__ import annotations
from datetime import date, datetime, timezone
from sqlalchemy import (
BigInteger,
Boolean,
CheckConstraint,
Date,
@@ -77,8 +73,6 @@ class Match(Base):
home_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))
# 数据血缘: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)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
@@ -94,7 +88,7 @@ class Match(Base):
foreign_keys=[away_team_id], back_populates="away_matches", lazy="selectin"
)
stats: Mapped["MatchStats | None"] = relationship(
back_populates="match", cascade="all, delete-orphan", lazy="select"
back_populates="match", cascade="all, delete-orphan", lazy="selectin"
)
predictions: Mapped[list["Prediction"]] = relationship(back_populates="match", cascade="all, delete-orphan")
@@ -111,24 +105,6 @@ class Match(Base):
"match_date_date",
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",
),
)
@@ -149,21 +125,12 @@ class MatchStats(Base):
away_yellow_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)
# 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)
# 数据血缘:追踪统计数据的来源和可用时间
source: Mapped[str | None] = mapped_column(String(30)) # bzzoiro / understat
source_record_id: Mapped[str | None] = mapped_column(String(100))
retrieved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
available_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# xG 数据血缘:单独追踪 xG 字段的来源与更新时间(xG 可能独立于其他统计被更新)
xg_source: Mapped[str | None] = mapped_column(String(30))
xg_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
xg_source_record_id: Mapped[str | None] = mapped_column(String(100))
match: Mapped[Match] = relationship(back_populates="stats")
@@ -173,40 +140,27 @@ class MatchStats(Base):
)
class Standing(Base):
"""联赛积分榜快照(bzzoiro /leagues/{id}/standings/)。
同一联赛同一赛季只保留最新快照:重新采集时按 (league_id, season, team_id)
upsertzone 来自 bzzoiro 分区( champions_league / europa_league / relegation)
"""
__tablename__ = "standings"
class Injury(Base):
"""球员伤停记录(api-football 数据源)。"""
__tablename__ = "injuries"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
league_id: Mapped[int] = mapped_column(ForeignKey("leagues.id"), nullable=False)
season: Mapped[str] = mapped_column(String(12), nullable=False)
team_id: Mapped[int] = mapped_column(ForeignKey("teams.id"), nullable=False)
position: Mapped[int] = mapped_column(Integer, nullable=False)
played: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
won: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
drawn: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
lost: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
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)
player_id: Mapped[int | None] = mapped_column(Integer, index=True)
player_name: Mapped[str] = mapped_column(String(120), nullable=False)
team_id: Mapped[int | None] = mapped_column(ForeignKey("teams.id"), index=True)
fixture_id: Mapped[int | None] = mapped_column(Integer)
league_id: Mapped[int | None] = mapped_column(Integer)
injury_type: Mapped[str | None] = mapped_column(String(50)) # Missing Fixture / Suspended
reason: Mapped[str | None] = mapped_column(String(200))
injury_date: Mapped[date | None] = mapped_column(Date, index=True)
return_date: Mapped[date | None] = mapped_column(Date)
retrieved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
league: Mapped[League] = relationship()
team: Mapped[Team] = relationship(lazy="selectin")
team: Mapped["Team | None"] = relationship()
__table_args__ = (
UniqueConstraint("league_id", "season", "team_id", name="uq_standings_league_season_team"),
Index("ix_standings_league_season_pos", "league_id", "season", "position"),
Index("ix_injuries_player_fixture", "player_id", "fixture_id", "injury_type", unique=True),
Index("ix_injuries_team_date", "team_id", "injury_date"),
)
@@ -214,7 +168,7 @@ class Prediction(Base):
__tablename__ = "predictions"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
match_id: Mapped[int] = mapped_column(ForeignKey("matches.id", ondelete="CASCADE"), nullable=False)
match_id: Mapped[int] = mapped_column(ForeignKey("matches.id"), nullable=False)
provider: Mapped[str] = mapped_column(String(30), nullable=False)
model: Mapped[str] = mapped_column(String(80), nullable=False)
prompt_version: Mapped[str] = mapped_column(String(20), nullable=False, default="v1")
@@ -223,9 +177,6 @@ class Prediction(Base):
latency_ms: Mapped[int | None] = mapped_column(Integer)
pred_home_goals: Mapped[float | None] = mapped_column(Float)
pred_away_goals: Mapped[float | None] = mapped_column(Float)
# 备选比分(次可能比分,可空)
alt_pred_home_goals: Mapped[int | None] = mapped_column(Integer)
alt_pred_away_goals: Mapped[int | None] = mapped_column(Integer)
pred_1x2: Mapped[str | None] = mapped_column(String(3))
subjective_confidence: Mapped[float | None] = mapped_column(Float) # LLM 主观置信度,非概率
reasoning: Mapped[str | None] = mapped_column(Text)
@@ -233,12 +184,8 @@ class Prediction(Base):
# multi-agent 模式: 各专家报告
mode: Mapped[str] = mapped_column(String(20), nullable=False, default="single")
agent_outputs: Mapped[dict | None] = mapped_column(JSONB)
# Fix: agent_weights 独立持久化到列(原本只在 raw_response 中)
agent_weights: Mapped[dict | None] = mapped_column(JSONB)
# 预测状态: success / failed / degraded
status: Mapped[str] = mapped_column(String(20), nullable=False, default="success")
# Fix: run_type 区分实盘(live)与回测(backtest),避免回测覆盖实盘预测
run_type: Mapped[str] = mapped_column(String(10), nullable=False, default="live")
# 时间语义:区分比赛时间、预测创建时间、数据截止时间
match_kickoff_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
prediction_created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
@@ -252,11 +199,10 @@ class Prediction(Base):
match: Mapped[Match] = relationship(back_populates="predictions")
__table_args__ = (
# Fix: 唯一约束增加 mode + run_type,允许 live 与 backtest 共存
# 防止回测覆盖未结算的实盘预测(后续 settle 会污染评估数据)
# P1-6: 数据库级唯一约束,防止同一 match+provider+model 产生重复预测
UniqueConstraint(
"match_id", "provider", "model", "mode", "run_type",
name="uq_predictions_match_provider_model_mode_run_type",
"match_id", "provider", "model",
name="uq_predictions_match_provider_model",
),
Index("ix_predictions_match", "match_id"),
Index("ix_predictions_provider_model", "provider", "model"),
@@ -267,138 +213,6 @@ class Prediction(Base):
CheckConstraint("pred_away_goals >= 0", name="ck_pred_away_goals_nonneg"),
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("mode IN ('single', 'multi', 'baseline')", name="ck_mode_enum"),
CheckConstraint("mode IN ('single', 'multi')", name="ck_mode_enum"),
CheckConstraint("status IN ('success', 'failed', 'degraded')", name="ck_status_enum"),
CheckConstraint("run_type IN ('live', 'backtest')", name="ck_run_type_enum"),
)
class AppSetting(Base):
"""后台管理的运行时设置(如数据源 API Key),读取时优先于 .env 默认值。"""
__tablename__ = "app_settings"
key: Mapped[str] = mapped_column(String(100), primary_key=True)
value: Mapped[str] = mapped_column(Text, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
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) ──────────────────────────────────
# Bronze 层、死信、质量监控、血缘追踪 4 张表。
class RawEvent(Base):
"""Bronze 层:采集到的原始事件存档,便于重放与审计。
预留未启用:当前 bzzoiro 管线不写入此表
未来接线计划:events 采集成功后写入 raw_payload,支持重放与审计
"""
__tablename__ = "raw_events"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
source_system: Mapped[str] = mapped_column(String(50), nullable=False)
source_record_id: Mapped[str] = mapped_column(String(100), nullable=False)
raw_payload: Mapped[dict] = mapped_column(JSONB, nullable=False)
ingested_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
ingest_batch_id: Mapped[str | None] = mapped_column(String(36))
__table_args__ = (
UniqueConstraint("source_system", "source_record_id", name="uq_raw_event"),
Index("ix_raw_event_batch", "ingest_batch_id"),
)
class IngestFailure(Base):
"""采集失败死信:记录失败原因、重试次数与下次重试时间。
bzzoiro 三条管线(events / standings / stats)抓取失败时经由
bzzoiro._safe_write_ingest_failure 写入本表(尽力而为,写入失败
不影响采集主流程)admin 可通过 /admin/schedules/ingest-failures
查看与重试
"""
__tablename__ = "ingest_failures"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
source_system: Mapped[str] = mapped_column(String(50), nullable=False)
entity_type: Mapped[str] = mapped_column(String(50), nullable=False)
source_record_id: Mapped[str | None] = mapped_column(String(100))
error_type: Mapped[str] = mapped_column(String(50), nullable=False)
error_detail: Mapped[str | None] = mapped_column(Text)
raw_payload: Mapped[dict | None] = mapped_column(JSONB)
retry_count: Mapped[int] = mapped_column(Integer, server_default="0")
next_retry_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
status: Mapped[str] = mapped_column(String(20), server_default="pending")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
__table_args__ = (
Index("ix_ingest_failure_status", "status", "next_retry_at"),
CheckConstraint(
"status IN ('pending', 'retrying', 'resolved', 'abandoned')",
name="ck_ingest_failure_status",
),
)
class DataQualityCheck(Base):
"""数据质量监控:记录每次质量检查的结果。
预留未启用:当前 bzzoiro 管线不写入此表
未来接线计划:定时检查比赛/统计/积分榜完整性,写入检查结果
"""
__tablename__ = "data_quality_checks"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
check_name: Mapped[str] = mapped_column(String(100), nullable=False)
entity_type: Mapped[str] = mapped_column(String(50), nullable=False)
entity_id: Mapped[int | None] = mapped_column(Integer)
expected_value: Mapped[float | None] = mapped_column(Float)
actual_value: Mapped[float] = mapped_column(Float, nullable=False)
passed: Mapped[bool] = mapped_column(Boolean, nullable=False)
severity: Mapped[str] = mapped_column(String(10), server_default="warning")
detail: Mapped[dict | None] = mapped_column(JSONB)
checked_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
__table_args__ = (
Index("ix_dqc_checked_at", "checked_at"),
Index("ix_dqc_entity", "entity_type", "entity_id"),
)
class DataLineage(Base):
"""ETL 血缘追踪:记录从源到目标的转换过程。
预留未启用:当前 bzzoiro 管线不写入此表
未来接线计划:每次采集写入 source_record_id target_table/id 映射
"""
__tablename__ = "data_lineage"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
source_system: Mapped[str] = mapped_column(String(50), nullable=False)
source_record_id: Mapped[str] = mapped_column(String(100), nullable=False)
target_table: Mapped[str] = mapped_column(String(50), nullable=False)
target_id: Mapped[int | None] = mapped_column(Integer)
transform_name: Mapped[str] = mapped_column(String(50), nullable=False)
transform_detail: Mapped[dict | None] = mapped_column(JSONB)
batch_id: Mapped[str | None] = mapped_column(String(36))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
__table_args__ = (
Index("ix_lineage_source", "source_system", "source_record_id"),
Index("ix_lineage_target", "target_table", "target_id"),
Index("ix_lineage_batch", "batch_id"),
)
+1 -1
View File
@@ -40,7 +40,7 @@ class MatchRepository:
) -> Match | None:
"""按联赛+主队+客队+日期查找比赛(天级匹配)。
预加载 stats:调用方(统计回填)会读取 existing.stats,
预加载 stats:调用方(understat 回填)会读取 existing.stats,
async session 下惰性加载会抛 MissingGreenlet
P2-3: 使用 match_date_date(已建索引)做等值匹配,避免 func.date()
+2 -2
View File
@@ -35,7 +35,7 @@ def load_agent_prompt(name: str, version: str = "v1") -> str:
@dataclass
class AgentSpec:
"""领域专家 agent 定义。"""
name: str # h2h / form / home_away / standings / stats
name: str # h2h / form / standings / injuries / xg
system_prompt: str # system message
slice_fn: object # async (header, before) -> str 切片函数
@@ -183,7 +183,7 @@ async def run_agent(
user=user_prompt,
json_mode=True,
temperature=0.2,
max_tokens=4096, # 推理模型需要更大余量
max_tokens=600,
)
if resp.error:
logger.warning("agent %s LLM failed: %s", spec.name, resp.error)
+65 -211
View File
@@ -13,7 +13,6 @@ from src.core.config import settings
from src.db.base import AsyncSessionLocal
from src.db.models import Match, Prediction
from src.db.unit_of_work import get_uow
from src.llm.predict import _upsert_prediction
from src.llm.agents.base import AgentReport, AgentSpec, load_agent_prompt
from src.llm.context_builder import (
MatchHeader,
@@ -21,22 +20,16 @@ from src.llm.context_builder import (
h2h_slice,
header_text,
home_away_slice,
injuries_slice,
load_match_header,
standings_slice,
stats_slice,
)
from src.core.runtime_config import get_runtime_value
from src.llm.provider import LLMProvider, get_default_provider
logger = logging.getLogger(__name__)
# P3-2: agent provider 配置缓存(TTL 60s),避免每次 _agent_provider 都多次查 DB
_AGENT_PROVIDER_CACHE: dict[str, tuple[float, LLMProvider]] = {}
_AGENT_PROVIDER_CACHE_TTL = 60.0
# ── 5 个专家 agent 定义 ──
# A=近期状态 B=攻防数据 C=主客因素 D=联赛排名 E=历史交锋
# A=近期状态 B=攻防数据 C=主客因素 D=阵容完整性 E=历史交锋
SPECIALIST_SPECS: list[AgentSpec] = [
AgentSpec(
name="form",
@@ -54,9 +47,9 @@ SPECIALIST_SPECS: list[AgentSpec] = [
slice_fn=home_away_slice,
),
AgentSpec(
name="standings",
system_prompt="你是足球联赛排名分析专家。分析积分榜位置、积分走势与分区,评估两队整体实力差距。只输出 JSON。",
slice_fn=standings_slice,
name="injuries",
system_prompt="你是足球阵容完整性分析专家。汇总伤停与停赛名单,输出战力缺失程度。只输出 JSON。",
slice_fn=injuries_slice,
),
AgentSpec(
name="h2h",
@@ -65,20 +58,7 @@ SPECIALIST_SPECS: list[AgentSpec] = [
),
]
AGGREGATOR_SYSTEM = (
"你是足球预测终裁专家。综合各领域专家报告输出最终预测。"
"引用专家时必须使用报告中的专家全名(如「攻防数据分析专家」),禁止使用英文代码。"
"只输出 JSON。"
)
# 专家代码 → 终裁/展示统一称呼
AGENT_LABELS_ZH: dict[str, str] = {
"form": "近期状态分析专家",
"stats": "攻防数据分析专家",
"home_away": "主客因素分析专家",
"standings": "联赛排名分析专家",
"h2h": "历史交锋分析专家",
}
AGGREGATOR_SYSTEM = "你是足球预测终裁专家。综合各领域报告输出最终预测。只输出 JSON。"
@dataclass
@@ -90,114 +70,62 @@ class MultiPredictResult:
mode: str
pred_home_goals: float | None
pred_away_goals: float | None
alt_pred_home_goals: int | None
alt_pred_away_goals: int | None
pred_1x2: str | None
subjective_confidence: float | None
reasoning: str | None
context: str
agent_outputs: list[dict]
agent_weights: dict | None
status: str = "success"
latency_ms: int | None = None
prompt_tokens: int | None = None
completion_tokens: int | None = None
raw: dict | None = None
context: str
latency_ms: int | None
raw: dict | None
async def _agent_provider(agent_id: str, *, tier: str, model_override: str | None = None) -> LLMProvider:
"""构造某 agent 专属 provider。
def _get_specialist_provider() -> LLMProvider:
"""专家模型: LLM_SPECIALIST_MODEL 回落 LLM_MODEL。"""
p = get_default_provider()
if settings.LLM_SPECIALIST_MODEL:
p.model = settings.LLM_SPECIALIST_MODEL
return p
覆盖优先级:
模型: 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
P3-2: 结果缓存 60 ,避免每次预测都多次查询运行时配置 DB
注意:model_override 生效时跳过缓存读写 否则带 override 的结果会泄漏给
不带 override 的调用(反之亦然),导致跨调用的模型串味
"""
cache_key = f"{agent_id}:{tier}"
if model_override is None:
cached = _AGENT_PROVIDER_CACHE.get(cache_key)
if cached is not None:
ts, provider = cached
if time.time() - ts < _AGENT_PROVIDER_CACHE_TTL:
return provider
pfx = f"AGENT_{agent_id.upper()}_"
p = await get_default_provider()
tier_model = settings.LLM_SPECIALIST_MODEL if tier == "specialist" else settings.LLM_AGGREGATOR_MODEL
if tier_model:
p.model = tier_model
model = await get_runtime_value(f"{pfx}MODEL")
if model:
p.model = model
# 调用方显式传入的 model 优先级最高,高于 agent 级与层级默认
if model_override:
p.model = model_override
base = await get_runtime_value(f"{pfx}BASE_URL")
if base:
p.base_url = base
key = await get_runtime_value(f"{pfx}API_KEY")
if key:
p.api_key = key
if model_override is None:
_AGENT_PROVIDER_CACHE[cache_key] = (time.time(), p)
# 简单淘汰:超过 20 条时清空(60s TTL 下不会累积太多)
if len(_AGENT_PROVIDER_CACHE) > 20:
_AGENT_PROVIDER_CACHE.clear()
def _get_aggregator_provider() -> LLMProvider:
"""终裁模型: LLM_AGGREGATOR_MODEL 回落 LLM_MODEL。"""
p = get_default_provider()
if settings.LLM_AGGREGATOR_MODEL:
p.model = settings.LLM_AGGREGATOR_MODEL
return p
async def run_specialists(
header: MatchHeader,
*,
provider: LLMProvider,
version: str = "v1",
before=None,
model_override: str | None = None,
) -> list[AgentReport]:
"""并行执行 5 个专家 agent。fail-open: 单个失败不影响其他。
before: 数据截止时间(回测防泄漏)None 表示不限制
model_override: 调用方显式指定的模型,覆盖各 agent 的层级默认
注意:model_override 必须作为形参下传,不能用模块级变量中转
backtest asyncio.gather 并发 8 场预测( backtest.py Semaphore(8)),
模块级变量会被并发调用互相覆盖,导致 A 场的预测用上 B 场的模型
"""
"""并行执行 5 个专家 agent。fail-open: 单个失败不影响其他。"""
tasks = [
_run_one(spec, header, await _agent_provider(spec.name, tier="specialist", model_override=model_override), version=version, before=before)
_run_one(spec, header, provider, version=version)
for spec in SPECIALIST_SPECS
]
results = await asyncio.gather(*tasks, return_exceptions=True)
reports: list[AgentReport] = []
for spec, r in zip(SPECIALIST_SPECS, results):
if isinstance(r, Exception):
logger.warning(
"专家调用失败 match=%s agent=%s error=%s",
header.match_id, spec.name, str(r)[:120],
)
logger.warning("agent %s raised: %s", spec.name, r)
reports.append(AgentReport(agent=spec.name, status="error", analysis=str(r)[:200]))
else:
reports.append(r)
return reports
async def _run_one(spec, header, provider, *, version, before=None) -> AgentReport:
async def _run_one(spec, header, provider, *, version) -> AgentReport:
from src.llm.agents.base import run_agent
return await run_agent(spec, header, provider, before=before, version=version)
return await run_agent(spec, header, provider, before=header.match_dt, version=version)
def _reports_to_json(reports: list[AgentReport]) -> str:
"""报告序列化: agent 字段直接用中文专家全名,引导终裁用统一称呼引用。"""
out = []
for r in reports:
d = r.to_dict()
d["agent"] = AGENT_LABELS_ZH.get(d.get("agent", ""), d.get("agent"))
out.append(d)
return json.dumps(out, ensure_ascii=False, indent=1)
return json.dumps([r.to_dict() for r in reports], ensure_ascii=False, indent=1)
async def run_aggregator(
@@ -219,7 +147,7 @@ async def run_aggregator(
user=user_prompt,
json_mode=True,
temperature=0.2,
max_tokens=4096, # 推理模型需要更大余量
max_tokens=1000,
)
if resp.error:
raise RuntimeError(f"aggregator LLM error: {resp.error}")
@@ -233,73 +161,26 @@ async def predict_match_multi(
*,
provider: LLMProvider | None = None,
version: str = "v1",
backtest: bool = False,
cutoff_at=None,
model: str | None = None,
) -> MultiPredictResult:
"""多 agent 端到端预测: 切片 → 并行专家 → 终裁 → 存库。
backtest: 回测模式True cutoff 自动设为 match_dt - 1
cutoff_at: 显式截止时间(优先于 backtest 自动计算)
model: 显式指定模型,优先于 agent /层级默认配置(single 模式语义一致)
"""
"""多 agent 端到端预测: 切片 → 并行专家 → 终裁 → 存库。"""
start = time.perf_counter()
# 1. 比赛头(各 agent 共享;不存在则 404)
header = await load_match_header(match_id)
match_kickoff_at = header.match_dt
prediction_cutoff_at = header.match_dt # 默认:比赛时间作为数据截止
now = datetime.now(timezone.utc)
# 计算真正的数据截止时间(回测防泄漏)
# 优先级: 显式 cutoff_at > backtest 自动计算 > 默认(比赛时间)
if cutoff_at is not None:
cutoff = cutoff_at
elif backtest and header.match_dt:
from datetime import timedelta
cutoff = header.match_dt - timedelta(days=1)
else:
cutoff = header.match_dt
prediction_cutoff_at = cutoff
# 2. 并行专家
specialist_provider = _get_specialist_provider()
reports = await run_specialists(header, provider=specialist_provider, version=version)
# 2. 并行专家(各自独立配置,使用统一 cutoff)
# model 作为形参下传,而非模块级变量:backtest 并发 8 场预测时,
# 模块级变量会被并发调用互相覆盖(模型串味)。
reports = await run_specialists(
header, version=version, before=cutoff, model_override=model
# 3. 终裁
aggregator_provider = _get_aggregator_provider()
final, agg_prompt_tokens, agg_completion_tokens = await run_aggregator(
header, reports, provider=aggregator_provider, version=version
)
# 2.5 统计有效专家报告数量
ok_reports = [r for r in reports if r.status == "ok"]
has_valid_data = len(ok_reports) > 0
# 3. 终裁(仅当有有效专家报告时执行)
if has_valid_data:
aggregator_provider = await _agent_provider("aggregator", tier="aggregator")
final, agg_prompt_tokens, agg_completion_tokens = await run_aggregator(
header, reports, provider=aggregator_provider, version=version
)
else:
# 所有专家无数据/均失败:跳过终裁,标记 degraded
logger.warning(
"预测降级 match=%s mode=%s status=degraded experts=%d/%d 均无有效数据",
match_id, "multi", len(ok_reports), len(reports),
)
# 无有效专家时不调用 aggregator provider,避免多余开销
# model 使用 settings 默认值占位(无实际 LLM 调用)
final = {
"pred_home_goals": None,
"pred_away_goals": None,
"alt_pred_home_goals": None,
"alt_pred_away_goals": None,
"pred_1x2": None,
"subjective_confidence": None,
"reasoning": f"所有 {len(reports)} 位专家均无有效数据或预测失败(状态: {','.join(r.status for r in reports)})",
"agent_weights": {},
}
agg_prompt_tokens = 0
agg_completion_tokens = 0
aggregator_model = model or settings.LLM_MODEL # 占位,无实际 LLM 调用
latency_ms = int((time.perf_counter() - start) * 1000)
# 3.5 计算输入 hash(基于终裁报告)
@@ -313,61 +194,39 @@ async def predict_match_multi(
if m is None:
raise ValueError(f"match {match_id} not found")
# 根据是否有有效数据决定校验策略
# 严格校验终裁输出
from src.llm.validation import validate_agent_weights, validate_prediction_output
try:
validated = validate_prediction_output(final)
except Exception as e:
raise RuntimeError(f"终裁输出校验失败: {e}")
if has_valid_data:
# 有有效报告:严格校验终裁输出
try:
validated = validate_prediction_output(final)
except Exception as e:
raise RuntimeError(f"终裁输出校验失败: {e}")
agent_weights = validate_agent_weights(final.get("agent_weights"))
pred_status = "success"
model_name = aggregator_provider.model
else:
# 无有效报告:跳过严格校验,直接构造降级结果
validated = None # type: ignore
agent_weights = {}
pred_status = "degraded"
model_name = aggregator_model
pred = await _upsert_prediction(
session,
# agent_weights 同样必须过校验(旧实现直接取 raw 值落库,未做任何检查)
agent_weights = validate_agent_weights(final.get("agent_weights"))
pred = Prediction(
match_id=match_id,
provider_name=settings.LLM_PROVIDER,
model=model_name,
provider=settings.LLM_PROVIDER,
model=aggregator_provider.model,
prompt_version=f"multi_{version}",
mode="multi",
run_type="backtest" if backtest else "live",
values={
"prompt_version": f"multi_{version}",
"prompt_tokens": sum(r.prompt_tokens or 0 for r in reports) + agg_prompt_tokens,
"completion_tokens": sum(r.completion_tokens or 0 for r in reports) + agg_completion_tokens,
"latency_ms": latency_ms,
"pred_home_goals": validated.pred_home_goals if validated else None,
"pred_away_goals": validated.pred_away_goals if validated else None,
"alt_pred_home_goals": validated.alt_pred_home_goals if validated else None,
"alt_pred_away_goals": validated.alt_pred_away_goals if validated else None,
"pred_1x2": validated.pred_1x2 if validated else None,
"subjective_confidence": validated.subjective_confidence if validated else None,
"reasoning": validated.reasoning if validated else final.get("reasoning", ""),
"raw_response": final,
"agent_outputs": [r.to_dict() for r in reports],
"agent_weights": agent_weights,
"status": pred_status,
"match_kickoff_at": match_kickoff_at,
"prediction_cutoff_at": prediction_cutoff_at,
"prediction_created_at": now,
"input_hash": input_hash,
},
)
logger.info(
"预测完成 match=%s mode=%s status=%s pred=%s:%s (%s) latency=%sms, experts=%d/%d, prediction_id=%s",
match_id, "multi", pred_status,
pred.pred_home_goals, pred.pred_away_goals, pred.pred_1x2,
latency_ms, len(ok_reports), len(reports), pred.id,
prompt_tokens=sum(r.prompt_tokens or 0 for r in reports) + agg_prompt_tokens,
completion_tokens=sum(r.completion_tokens or 0 for r in reports) + agg_completion_tokens,
latency_ms=latency_ms,
pred_home_goals=validated.pred_home_goals,
pred_away_goals=validated.pred_away_goals,
pred_1x2=validated.pred_1x2,
subjective_confidence=validated.subjective_confidence,
reasoning=validated.reasoning,
raw_response=final,
agent_outputs=[r.to_dict() for r in reports],
status="success",
match_kickoff_at=match_kickoff_at,
prediction_cutoff_at=prediction_cutoff_at,
prediction_created_at=now,
input_hash=input_hash,
)
session.add(pred)
await session.refresh(pred)
return MultiPredictResult(
prediction_id=pred.id,
@@ -377,17 +236,12 @@ async def predict_match_multi(
mode="multi",
pred_home_goals=pred.pred_home_goals,
pred_away_goals=pred.pred_away_goals,
alt_pred_home_goals=pred.alt_pred_home_goals,
alt_pred_away_goals=pred.alt_pred_away_goals,
pred_1x2=pred.pred_1x2,
subjective_confidence=pred.subjective_confidence,
reasoning=pred.reasoning,
status=pred_status,
agent_outputs=pred.agent_outputs,
agent_weights=agent_weights,
context=_reports_to_json(reports),
latency_ms=latency_ms,
prompt_tokens=pred.prompt_tokens,
completion_tokens=pred.completion_tokens,
raw=final,
)
+5 -58
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass, field
from datetime import datetime, timezone
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.orm import selectinload
@@ -20,7 +20,6 @@ from src.db.unit_of_work import get_uow
from src.llm.eval import settle_prediction
from src.llm.predict import predict_match
from src.llm.utils import actual_1x2
from src.data.team_names_zh import zh_name
logger = logging.getLogger(__name__)
@@ -32,8 +31,6 @@ class BacktestMatchResult:
league_code: str | None
home_team: str
away_team: str
home_team_zh: str | None
away_team_zh: str | None
match_date: str
actual_home: int
actual_away: int
@@ -57,8 +54,6 @@ class BacktestCandidate:
league_code: str | None
home_team: str
away_team: str
home_team_zh: str | None
away_team_zh: str | None
match_date: datetime
home_goals: int
away_goals: int
@@ -70,8 +65,6 @@ class BacktestSummary:
"""回测汇总统计。"""
total: int
scored: int
success: int = 0 # status=success 的预测数(有完整比分+1x2)
degraded: int = 0 # status=degraded 的预测数(专家失败/无有效数据)
accuracy_1x2: float | None = None
avg_score_rmse: float | None = None
avg_subjective_confidence: float | None = None
@@ -79,35 +72,6 @@ class BacktestSummary:
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(
db,
*,
@@ -135,12 +99,10 @@ async def _get_historical_matches(
)
if league_id is not None:
stmt = stmt.where(Match.league_id == league_id)
dt_from = _parse_date_bound(date_from, end_of_day=False)
if dt_from is not None:
stmt = stmt.where(Match.match_date >= dt_from)
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)
if date_from:
stmt = stmt.where(Match.match_date >= date_from)
if date_to:
stmt = stmt.where(Match.match_date <= date_to)
stmt = stmt.order_by(Match.match_date.desc()).limit(limit)
result = await db.execute(stmt)
@@ -151,8 +113,6 @@ async def _get_historical_matches(
league_code=m.league.code if m.league else None,
home_team=m.home_team.name if m.home_team else "?",
away_team=m.away_team.name if m.away_team else "?",
home_team_zh=zh_name(m.home_team.name) if m.home_team else None,
away_team_zh=zh_name(m.away_team.name) if m.away_team else None,
match_date=m.match_date,
home_goals=m.home_goals,
away_goals=m.away_goals,
@@ -204,8 +164,6 @@ async def run_backtest(
league_code=c.league_code,
home_team=c.home_team,
away_team=c.away_team,
home_team_zh=c.home_team_zh,
away_team_zh=c.away_team_zh,
match_date=c.match_date.strftime("%Y-%m-%d") if c.match_date else "?",
actual_home=c.home_goals,
actual_away=c.away_goals,
@@ -227,17 +185,6 @@ async def run_backtest(
if r is not None:
summary.results.append(r)
summary.scored += 1
# success:有完整预测比分+1x2;degraded:多专家模式无有效结论
if r.pred_1x2 is not None and r.pred_home is not None and r.pred_away is not None:
summary.success += 1
else:
summary.degraded += 1
logger.info(
"回测汇总 mode=%s total=%d scored=%d success=%d accuracy=%s%%",
mode, summary.total, summary.scored, summary.success,
f"{(sum(1 for r in summary.results if r.correct_1x2) / summary.scored * 100):.1f}" if summary.scored else "n/a",
)
# 汇总统计
if summary.scored > 0:
-113
View File
@@ -1,113 +0,0 @@
"""极简基线预测:主客场场均进球估计(不调用 LLM,不产生费用)。
用于与 LLM 预测做 eval 对比这是最简单的统计基线,仅供研究参考,
文档与 reasoning 均明确标注非投注建议
"""
from __future__ import annotations
import logging
from datetime import datetime
from sqlalchemy import case, func, select
from src.db.base import AsyncSession, AsyncSessionLocal
from src.db.models import Match
logger = logging.getLogger(__name__)
async def _avg_goals(
db: AsyncSession,
*,
team_id: int,
side: str,
league_id: int,
before: datetime | None,
) -> float:
"""某队在该联赛已完赛场次的场均进球(side=home/away)。"""
if side == "home":
goals_col = Match.home_goals
team_col = Match.home_team_id
else:
goals_col = Match.away_goals
team_col = Match.away_team_id
stmt = (
select(func.avg(goals_col).label("avg_goals"), func.count().label("cnt"))
.where(
Match.match_status == "finished",
team_col == team_id,
Match.league_id == league_id,
goals_col.is_not(None),
)
)
if before is not None:
stmt = stmt.where(Match.match_date < before)
row = (await db.execute(stmt)).one()
return float(row.avg_goals) if row.avg_goals is not None and row.cnt > 0 else 0.0
async def predict_baseline(
match_id: int,
*,
backtest: bool = False,
cutoff_at: datetime | None = None,
) -> dict:
"""极简基线预测:主场场均进球 vs 客场场均进球。
返回与 PredictResult 兼容的字典:
provider=model="baseline", 不调用 LLM,latency_ms0
"""
async with AsyncSessionLocal() as db:
match = await db.get(Match, match_id)
if match is None:
raise ValueError(f"match {match_id} not found")
before = None
if backtest and match.match_dt:
from datetime import timedelta
before = match.match_dt - timedelta(days=1)
elif cutoff_at is not None:
before = cutoff_at
home_avg = await _avg_goals(
db, team_id=match.home_team_id, side="home",
league_id=match.league_id, before=before,
)
away_avg = await _avg_goals(
db, team_id=match.away_team_id, side="away",
league_id=match.league_id, before=before,
)
pred_home = max(0, min(10, round(home_avg)))
pred_away = max(0, min(10, round(away_avg)))
# 主场轻微加成(可选,这里保持极简不额外加权)
if pred_home > pred_away:
pred_1x2 = "1"
elif pred_home < pred_away:
pred_1x2 = "2"
else:
pred_1x2 = "X"
return {
"pred_home_goals": float(pred_home),
"pred_away_goals": float(pred_away),
"alt_pred_home_goals": None,
"alt_pred_away_goals": None,
"pred_1x2": pred_1x2,
"subjective_confidence": 0.5,
"prompt_tokens": 0,
"completion_tokens": 0,
"reasoning": (
f"基线估计(非投注建议): 主队主场场均进球 {home_avg:.2f} → 预测 {pred_home}; "
f"客队客场场均进球 {away_avg:.2f} → 预测 {pred_away}"
),
"provider": "baseline",
"model": "baseline",
"prompt_version": "baseline_v1",
"mode": "baseline",
"status": "success",
"latency_ms": 0,
"raw": {"home_avg": round(home_avg, 2), "away_avg": round(away_avg, 2)},
}
+57 -131
View File
@@ -2,7 +2,7 @@
架构:
- match_header: 比赛基础信息(对阵双方/联赛/时间)
- 切片函数: 每个领域 agent 一个数据切片(h2h / form / standings / stats)
- 切片函数: 每个领域 agent 一个数据切片(h2h / form / standings / injuries / xg)
- build_context: agent 路径,拼接全部切片(行为与旧版一致)
multi-agent 路径由 agents/orchestrator.py 调用切片函数,每个专家只拿自己的切片
@@ -40,23 +40,11 @@ def _outcome(home_goals: int, away_goals: int, side: str) -> str:
def _is_stats_available(stats, before) -> bool:
"""检查统计数据在 cutoff 时间是否已可用。
available_at 语义:该条统计对外可被使用的最早时间,
至少不得早于比赛结束用于回测防泄漏
规则:
- before is None(实盘):available_at None 时允许(兼容旧数据)
- before is not None(回测):available_at None 视为不可用(保守)
- available_at > cutoff:不可用(数据在 cutoff 之后才生成)
"""
"""检查统计数据在 cutoff 时间是否已可用。"""
if before is None:
# 实盘模式:无时间信息时允许(兼容旧数据)
return True
# 回测模式(cutoff 不为 None):
# available_at 为 None → 无法确认是否在 cutoff 前可用,保守视为不可用
if stats.available_at is None:
return False
return True # 无时间信息时保守处理:允许使用
return stats.available_at <= before
@@ -81,9 +69,8 @@ class MatchContext:
match_id: int
text: str
has_stats: bool
has_standings: bool
has_injuries: bool
match_dt: object | None = None # 比赛时间(回测防泄漏 + 快照用)
cutoff: object | None = None # 实际使用的数据截止时间(用于落库记录)
@dataclass
@@ -157,38 +144,20 @@ async def h2h_slice(header: MatchHeader, *, limit: int = 8, before=None, db: Asy
lines = [f"── 历史交锋(近 {limit} 次) ──"]
n_with_score = 0
if h2h:
# 从当前主队视角统计:判断当前主队在每场交锋中是主是客
current_home_wins = current_home_draws = current_home_losses = 0
home_wins = draws = away_wins = 0
for hm in h2h:
d = hm.match_date.strftime("%Y-%m") if hm.match_date else "?"
if hm.home_goals is not None:
n_with_score += 1
# 判断当前主队当时是主队还是客队
if hm.home_team_id == header.home_team_id:
# 当前主队当时是主队
if hm.home_goals > hm.away_goals:
current_home_wins += 1
elif hm.home_goals == hm.away_goals:
current_home_draws += 1
else:
current_home_losses += 1
else:
# 当前主队当时是客队(从客队视角看赛果)
if hm.away_goals > hm.home_goals:
current_home_wins += 1
elif hm.away_goals == hm.home_goals:
current_home_draws += 1
else:
current_home_losses += 1
if hm.home_goals > hm.away_goals: home_wins += 1
elif hm.home_goals == hm.away_goals: draws += 1
else: away_wins += 1
lines.append(f" {d}: {hm.home_team.name} {hm.home_goals}-{hm.away_goals} {hm.away_team.name}")
else:
lines.append(f" {d}: {hm.home_team.name} vs {hm.away_team.name} (无比分)")
total = current_home_wins + current_home_draws + current_home_losses
total = home_wins + draws + away_wins
if total:
lines.append(
f" 总计 {total} 场(从当前主队 {header.home_name} 视角): "
f"{current_home_wins}{current_home_draws}{current_home_losses}"
)
lines.append(f" 总计 {total} 场: 主队 {home_wins}{draws}{away_wins}")
else:
lines.append(" 无数据")
# has_data 以「有比分的交锋」为准:仅有对阵无比分时不足以支撑分析
@@ -209,18 +178,14 @@ async def form_slice(header: MatchHeader, *, limit: int = 5, before=None, db: As
away_form = await _get_form(new_db, header.away_team_id, before=before, limit=limit)
lines = []
n_scored = 0
# P0-1 修复:每场历史比赛必须根据「该队当时是主是客」判断 side,
# 不能用本场 side 硬套 —— 否则客场输球会被算成主场赢球。
for label, name, form, team_id in (
("主队", header.home_name, home_form, header.home_team_id),
("客队", header.away_name, away_form, header.away_team_id),
for label, name, form, side in (
("主队", header.home_name, home_form, "home"),
("客队", header.away_name, away_form, "away"),
):
lines.append(f"── {label}近况({name},近 {limit} 场) ──")
if form:
wins = draws = losses = 0
for fm in form:
is_home = (fm.home_team_id == team_id)
side = "home" if is_home else "away"
o = _outcome(fm.home_goals, fm.away_goals, side)
if o == "W": wins += 1
elif o == "D": draws += 1
@@ -230,9 +195,9 @@ async def form_slice(header: MatchHeader, *, limit: int = 5, before=None, db: As
score = f"{fm.home_goals}-{fm.away_goals}" if fm.home_goals is not None else "vs"
xg = ""
if fm.stats and _is_stats_available(fm.stats, before) and fm.stats.home_xg is not None:
own = fm.stats.home_xg if is_home else fm.stats.away_xg
own = fm.stats.home_xg if side == "home" else fm.stats.away_xg
xg = f" (xG {own:.1f})"
opp = fm.away_team.name if is_home else fm.home_team.name
opp = fm.away_team.name if side == "home" else fm.home_team.name
lines.append(f" {o} {score} vs {opp}{xg}")
lines.append(f"{len(form)} 场: {wins}{draws}{losses}")
else:
@@ -254,33 +219,30 @@ async def stats_slice(header: MatchHeader, *, limit: int = 10, before=None, db:
away_form = await _get_form(new_db, header.away_team_id, before=before, limit=limit)
lines = [f"── 攻防数据(近 {limit} 场) ──"]
n_total = 0
# P0-1 修复:每场历史比赛必须根据「该队当时是主是客」判断 side,
# 不能用本场 side 硬套 —— 否则进球/失球/xG 全部算反。
for label, name, form, team_id in (
("主队", header.home_name, home_form, header.home_team_id),
("客队", header.away_name, away_form, header.away_team_id),
for label, name, form, side in (
("主队", header.home_name, home_form, "home"),
("客队", header.away_name, away_form, "away"),
):
if form:
gf = ga = shots = sot = poss = xg = xga = 0
n = n_shots = n_poss = n_xg = 0
for fm in form:
if fm.home_goals is None: continue
is_home = (fm.home_team_id == team_id)
gf += fm.home_goals if is_home else fm.away_goals
ga += fm.away_goals if is_home else fm.home_goals
gf += fm.home_goals if side == "home" else fm.away_goals
ga += fm.away_goals if side == "home" else fm.home_goals
n += 1
# 只使用 cutoff 之前已可用的统计数据
if fm.stats and _is_stats_available(fm.stats, before):
if fm.stats.home_shots is not None:
shots += fm.stats.home_shots if is_home else fm.stats.away_shots
sot += fm.stats.home_shots_on_target if is_home else fm.stats.away_shots_on_target
shots += fm.stats.home_shots if side == "home" else fm.stats.away_shots
sot += fm.stats.home_shots_on_target if side == "home" else fm.stats.away_shots_on_target
n_shots += 1
if fm.stats.home_possession is not None:
poss += fm.stats.home_possession if is_home else (100 - fm.stats.home_possession)
poss += fm.stats.home_possession if side == "home" else (100 - fm.stats.home_possession)
n_poss += 1
if fm.stats.home_xg is not None:
xg += fm.stats.home_xg if is_home else fm.stats.away_xg
xga += fm.stats.away_xg if is_home else fm.stats.home_xg
xg += fm.stats.home_xg if side == "home" else fm.stats.away_xg
xga += fm.stats.away_xg if side == "home" else fm.stats.home_xg
n_xg += 1
n_total += n
if n > 0:
@@ -337,99 +299,64 @@ 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)
async def standings_slice(header: MatchHeader, *, before=None, db: AsyncSession | None = None) -> SliceResult:
"""D - 联赛排名切片: 两队积分榜位置、积分、近期走势(form)、分区,评估整体实力差距
async def injuries_slice(header: MatchHeader, *, before=None, db: AsyncSession | None = None) -> SliceResult:
"""D - 阵容完整性切片: 伤停与停赛名单,评估战力缺失程度
before 参数保留与其他切片一致的签名(积分榜是最新快照,无历史版本,不受 cutoff 影响)
before=cutoff: 只使用 cutoff 之前已采集的伤停数据,防回测泄漏
db: 可选共享 session(见模块 docstring)
语义区分:
- 两队都有积分榜行 has_data=True(明确的排名信息)
- 任一队缺失 has_data=False(升班马/杯赛无榜,信息不完整时明确声明)
"""
from src.db.models import League, Standing
from src.data.injuries import get_injuries_for_match
cutoff = before or header.match_dt
if db is not None:
league = (await db.execute(select(League).where(League.id == header.league_id))).scalar_one_or_none()
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 []
)
home_injuries = await get_injuries_for_match(db, header.home_team_id, cutoff, as_of=cutoff)
away_injuries = await get_injuries_for_match(db, header.away_team_id, cutoff, as_of=cutoff)
else:
async with AsyncSessionLocal() as new_db:
return await standings_slice(header, before=before, db=new_db)
home_injuries = await get_injuries_for_match(new_db, header.home_team_id, cutoff, as_of=cutoff)
away_injuries = await get_injuries_for_match(new_db, header.away_team_id, cutoff, as_of=cutoff)
lines = [f"── 联赛排名({header.league_name}{len(rows)} 队) ──"]
lines = ["── 阵容完整性 ──"]
n_records = 0
def _fmt(row) -> str:
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 ""
form = f" 近5场 {row.form}" if row.form else ""
zone = f" [{row.zone}]" if row.zone else ""
return (
f" {row.position}: {row.points} 分 / {row.played}"
f"({row.won}{row.drawn}{row.lost}负, 进{row.goals_for}{row.goals_against} 净胜{row.goal_diff:+d}"
f"{zg}){form}{zone}"
)
for label, team_id in (("主队", header.home_team_id), ("客队", header.away_team_id)):
row = next((r for r in rows if r.team_id == team_id), None)
if row is None:
lines.append(f" {label}: 暂无积分榜数据(可能杯赛/赛季未开始)")
for label, injuries in (("主队", home_injuries), ("客队", away_injuries)):
if injuries:
n_records += len(injuries)
lines.append(f" {label}伤停({len(injuries)}人):")
for inj in injuries[:8]: # 最多显示 8 条
reason = inj.reason or inj.injury_type or "未知"
lines.append(f" - {inj.player_name}: {reason}")
if len(injuries) > 8:
lines.append(f" ...及其他 {len(injuries) - 8}")
else:
n_records += 1
lines.append(f" {label} {header.home_name if label == '主队' else header.away_name}:")
lines.append(_fmt(row))
lines.append(f" {label}: 无伤停数据")
# 两队排名对比摘要
home_row = next((r for r in rows if r.team_id == header.home_team_id), None)
away_row = next((r for r in rows if r.team_id == header.away_team_id), None)
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 n_records == 0:
return SliceResult(text="── 阵容完整性 ──\n 无数据", has_data=False, n_records=0)
# has_data: 两队都有行才算完整;只有一队时仍有价值,但标记不完整
has_data = n_records >= 1
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)
# ============================================================
# 单 agent 路径: 拼接全部切片(行为与旧版一致)
# ============================================================
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,防未来信息)。
async def build_context(match_id: int, *, form_last: int = 5, h2h_last: int = 5, backtest: bool = False) -> MatchContext:
"""单 agent 路径的完整上下文: 拼接全部切片(before=比赛时间,防未来信息)。
has_stats / has_standings 直接取切片显式声明的 has_data,
has_stats / has_injuries 直接取切片显式声明的 has_data,
不再靠文案子串匹配(见审查报告 P2-1)
P2-6: backtest=True cutoff = match_date - 1,确保只用赛前数据
cutoff_at: 显式截止时间(优先于 backtest 自动计算)
P1-1: 使用单个共享 session 贯穿所有切片查询,避免连接池耗尽
"""
async with AsyncSessionLocal() as db:
header = await load_match_header(match_id, db=db)
# 计算数据截止时间: 显式 > backtest 自动计算 > 默认(比赛时间)
if cutoff_at is not None:
cutoff = cutoff_at
elif backtest and header.match_dt:
# P2-6: 回测模式下 cutoff 提前 1 天,防止比赛日数据泄漏
cutoff = header.match_dt
if backtest and header.match_dt:
from datetime import timedelta
cutoff = header.match_dt - timedelta(days=1)
else:
cutoff = header.match_dt
parts = [header_text(header), ""]
form_res = await form_slice(header, limit=form_last, before=cutoff, db=db)
@@ -448,16 +375,15 @@ async def build_context(match_id: int, *, form_last: int = 5, h2h_last: int = 5,
parts.append(home_away_res.text)
parts.append("")
standings_res = await standings_slice(header, before=cutoff, db=db)
parts.append(standings_res.text)
injuries_res = await injuries_slice(header, before=cutoff, db=db)
parts.append(injuries_res.text)
return MatchContext(
match_id=match_id,
text="\n".join(parts),
has_stats=form_res.has_data or stats_res.has_data,
has_standings=standings_res.has_data,
has_injuries=injuries_res.has_data,
match_dt=header.match_dt,
cutoff=cutoff,
)
+18 -131
View File
@@ -3,32 +3,23 @@ from __future__ import annotations
import logging
from sqlalchemy import func, or_, select
from sqlalchemy import select
from src.db.models import Prediction, Match, League
from src.db.models import Prediction
from src.db.unit_of_work import get_uow
logger = logging.getLogger(__name__)
async def settle_prediction(prediction_id: int, home_goals: int, away_goals: int) -> Prediction:
"""回填实际结果。
拒绝结算 status degraded/failed 的预测(无有效预测数据)
"""
"""回填实际结果。"""
async with get_uow() as session:
pred = await session.get(Prediction, prediction_id)
if pred is None:
raise ValueError(f"prediction {prediction_id} not found")
if pred.status in ("degraded", "failed"):
raise ValueError(f"无法结算 status={pred.status} 的预测(无有效预测数据)")
pred.actual_home_goals = home_goals
pred.actual_away_goals = away_goals
pred.settled = True
logger.info(
"结算完成 prediction_id=%s match=%s actual=%s:%s mode=%s",
prediction_id, pred.match_id, home_goals, away_goals, pred.mode or "single",
)
return pred
@@ -41,152 +32,48 @@ def _actual_1x2(home: int, away: int) -> str:
return "2"
def _build_filters(
provider: str | None = None,
model: str | None = None,
prompt_version: str | None = None,
mode: str | None = None,
league_code: str | None = None,
) -> list:
"""构建评估筛选条件(参数化列明,防拼接注入)。"""
filters = [Prediction.settled == True]
if provider:
filters.append(Prediction.provider == provider)
if model:
filters.append(Prediction.model == model)
if prompt_version:
filters.append(Prediction.prompt_version == prompt_version)
if mode:
filters.append(Prediction.mode == mode)
if league_code:
league_subq = select(League.id).where(League.code == league_code).scalar_subquery()
filters.append(Prediction.match_id.in_(
select(Match.id).where(Match.league_id.in_(league_subq))
))
return filters
async def get_eval_summary(
limit: int = 1000,
*,
provider: str | None = None,
model: str | None = None,
prompt_version: str | None = None,
mode: str | None = None,
league_code: str | None = None,
) -> dict:
"""按 provider × 模型聚合评估。
P3-4: 默认限制评估最近 1000 条已结算预测,避免全表加载导致内存压力
只统计有效预测:
- settled == True
- status == "success"
- 预测比分字段齐全
degraded 或无比分的预测不计入准确率
"""
filters = _build_filters(provider, model, prompt_version, mode, league_code)
async def get_eval_summary() -> dict:
"""按 provider × 模型聚合评估。"""
async with get_uow() as session:
total_settled = (await session.execute(
select(func.count()).where(Prediction.settled == True)
)).scalar_one()
filtered_settled = (await session.execute(
select(func.count()).where(*filters)
)).scalar_one()
skipped_degraded = (await session.execute(
select(func.count()).where(
Prediction.settled == True,
or_(Prediction.status != "success", Prediction.status.is_(None)),
)
)).scalar_one()
# 有效评估行: settled + status=success + 筛选条件
stmt = (
select(Prediction)
.where(Prediction.settled == True, Prediction.status == "success", *filters)
.order_by(Prediction.id.desc())
.limit(limit)
.where(Prediction.settled == True)
)
rows = list((await session.execute(stmt)).scalars().all())
result = await session.execute(stmt)
rows = list(result.scalars().all())
from collections import defaultdict
buckets: dict[tuple[str, str, str], dict] = defaultdict(lambda: {
buckets: dict[tuple[str, str], dict] = defaultdict(lambda: {
"total": 0, "correct_1x2": 0, "score_errors": [], "conf_sum": 0.0, "conf_count": 0,
# 置信度校准分桶(仅 settled 且 pred 完整者计入)
"conf_buckets": {
"low(0-0.5)": {"total": 0, "correct": 0},
"medium(0.5-0.7)": {"total": 0, "correct": 0},
"high(0.7-1)": {"total": 0, "correct": 0},
},
})
evaluated = 0
skipped_incomplete = 0
for p in rows:
if (p.pred_home_goals is None or p.pred_away_goals is None or p.pred_1x2 is None):
skipped_incomplete += 1
continue
key = (p.provider, p.model, p.prompt_version or "")
key = (p.provider, p.model)
b = buckets[key]
b["total"] += 1
evaluated += 1
correct = False
if p.actual_home_goals is not None and p.actual_away_goals is not None:
actual = _actual_1x2(p.actual_home_goals, p.actual_away_goals)
if p.pred_1x2 == actual:
b["correct_1x2"] += 1
correct = True
if (
p.pred_home_goals is not None and p.pred_away_goals is not None
and p.actual_home_goals is not None and p.actual_away_goals is not None
):
if p.actual_home_goals is None or p.actual_away_goals is None:
continue
actual = _actual_1x2(p.actual_home_goals, p.actual_away_goals)
if p.pred_1x2 == actual:
b["correct_1x2"] += 1
if p.pred_home_goals is not None and p.pred_away_goals is not None:
err = ((p.pred_home_goals - p.actual_home_goals) ** 2 +
(p.pred_away_goals - p.actual_away_goals) ** 2) ** 0.5
b["score_errors"].append(err)
if p.subjective_confidence is not None:
b["conf_sum"] += p.subjective_confidence
b["conf_count"] += 1
# 仅当有实际结果可用于校准时,才落入置信度分桶
if p.actual_home_goals is not None and p.actual_away_goals is not None:
conf = p.subjective_confidence
if conf < 0.5:
bucket = "low(0-0.5)"
elif conf < 0.7:
bucket = "medium(0.5-0.7)"
else:
bucket = "high(0.7-1)"
b["conf_buckets"][bucket]["total"] += 1
if correct:
b["conf_buckets"][bucket]["correct"] += 1
summary = []
for (prov, model, ver), b in sorted(buckets.items()):
for (prov, model), b in sorted(buckets.items()):
acc = (b["correct_1x2"] / b["total"] * 100) if b["total"] else 0
avg_err = (sum(b["score_errors"]) / len(b["score_errors"])) if b["score_errors"] else None
avg_conf = (b["conf_sum"] / b["conf_count"]) if b["conf_count"] else None
# 校准分桶 → 命中率
calibration = {}
for name, cb in b["conf_buckets"].items():
hit_rate = round(cb["correct"] / cb["total"] * 100, 1) if cb["total"] else None
calibration[name] = {"total": cb["total"], "hit_rate": hit_rate}
summary.append({
"provider": prov,
"model": model,
"prompt_version": ver or None,
"total": b["total"],
"accuracy_1x2": round(acc, 1),
"avg_score_rmse": round(avg_err, 2) if avg_err is not None else None,
"avg_subjective_confidence": round(avg_conf, 2) if avg_conf is not None else None,
"calibration": calibration,
})
return {
"summary": summary,
"total_settled": total_settled,
"filtered_settled": filtered_settled,
"evaluated": evaluated,
"skipped_degraded": skipped_degraded,
"skipped_incomplete": skipped_incomplete,
}
return {"summary": summary}
+32 -121
View File
@@ -8,10 +8,9 @@ import time
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from threading import Lock
from src.core.config import settings
from sqlalchemy import select
from src.db.base import AsyncSessionLocal
from src.db.models import Match, Prediction
from src.db.unit_of_work import get_uow
@@ -24,7 +23,6 @@ _PROMPT_DIR = Path(__file__).resolve().parent / "prompts"
# ── LLM 响应缓存(match+provider+model+version → 结果) ──
_CACHE_TTL_SEC = 300 # 5 分钟
_CACHE_MAX_SIZE = 200 # P3-1: 有上限,避免长期运行内存无限增长
# P1-5: 缓存仅在 asyncio 协程内同步访问(dict 操作 GIL 原子),无需 threading.Lock。
# 删除 _cache_lock,避免同步锁阻塞事件循环;dict 的 get/set 在 CPython 下原子。
_cache: dict[str, tuple[float, PredictResult]] = {}
@@ -56,10 +54,6 @@ def _set_cached(match_id: int, provider: str, model: str, version: str, tpl_hash
# P1-5: 无锁写入。同上,dict set 原子。
key = _cache_key(match_id, provider, model, version, tpl_hash)
_cache[key] = (time.time(), result)
# P3-1: 超过上限时淘汰最旧条目(按时间戳排序)
if len(_cache) > _CACHE_MAX_SIZE:
oldest_key = min(_cache, key=lambda k: _cache[k][0])
_cache.pop(oldest_key, None)
def clear_prompt_cache() -> None:
@@ -95,57 +89,12 @@ class PredictResult:
prompt_version: str
pred_home_goals: float | None
pred_away_goals: float | None
alt_pred_home_goals: int | None
alt_pred_away_goals: int | None
pred_1x2: str | None
subjective_confidence: float | None
reasoning: str | None
context: str
status: str = "success"
latency_ms: int | None = None
raw: dict | None = None
async def _upsert_prediction(
session,
*,
match_id: int,
provider_name: str,
model: str,
mode: str,
run_type: str,
values: dict,
) -> Prediction:
"""按 (match, provider, model, mode, run_type) 唯一约束写入预测。
已存在且未结算 覆盖更新(重新预测语义);已结算 拒绝(保护评估数据)
run_type 区分 live/backtest,避免回测覆盖实盘预测
"""
existing = (
await session.execute(
select(Prediction).where(
Prediction.match_id == match_id,
Prediction.provider == provider_name,
Prediction.model == model,
Prediction.mode == mode,
Prediction.run_type == run_type,
)
)
).scalar_one_or_none()
if existing is not None and existing.settled:
raise ValueError("该比赛已有已结算的预测,不能重新预测")
pred = existing if existing is not None else Prediction(
match_id=match_id, provider=provider_name, model=model,
)
pred.mode = mode
pred.run_type = run_type
for k, v in values.items():
setattr(pred, k, v)
if existing is None:
session.add(pred)
await session.flush() # 拿到自增 id;事务由 UnitOfWork 退出时提交
return pred
latency_ms: int | None
raw: dict | None
async def predict_match(
@@ -157,24 +106,15 @@ async def predict_match(
mode: str = "multi",
use_cache: bool = True,
backtest: bool = False,
cutoff_at=None,
) -> "PredictResult | MultiPredictResult":
"""预测入口。mode=multi(默认)走多 agent;mode=single 走单次调用;mode=baseline 走无 LLM 基线
"""预测入口。mode=multi(默认)走多 agent;mode=single 走单次调用。
Args:
mode: multi(默认,5 专家+终裁) / single(单次) / baseline(极简统计基线,不调用 LLM)
use_cache:是否允许返回进程内缓存结果回测必须传 False
use_cache: 是否允许返回进程内缓存结果回测必须传 False
缓存命中不会新建 prediction ,调用方会对同一个 prediction_id
反复 settle,把不同比赛的真实比分覆盖到同一条记录上
backtest:是否回测模式True cutoff 自动设为 match_date-1
cutoff_at:显式截止时间,优先级高于 backtest 自动计算
backtest: 是否回测模式True build_context 使用 match_date-1 作为 cutoff
"""
if mode == "baseline":
from src.llm.baseline import predict_baseline
return await predict_baseline(
match_id, backtest=backtest, cutoff_at=cutoff_at,
)
if mode == "single":
return await _predict_single(
match_id,
@@ -183,19 +123,10 @@ async def predict_match(
prompt_version=prompt_version,
use_cache=use_cache,
backtest=backtest,
cutoff_at=cutoff_at,
)
from src.llm.agents.orchestrator import predict_match_multi
# 回测参数 + 模型覆盖完整传递到 multi-agent 路径
return await predict_match_multi(
match_id,
provider=provider,
version=(prompt_version or "v1").removeprefix("multi_"),
backtest=backtest,
cutoff_at=cutoff_at,
model=model,
)
return await predict_match_multi(match_id, provider=provider, version=(prompt_version or "v1").removeprefix("multi_"))
async def _predict_single(
@@ -206,11 +137,10 @@ async def _predict_single(
prompt_version: str | None = None,
use_cache: bool = True,
backtest: bool = False,
cutoff_at=None,
) -> PredictResult:
"""单次调用路径(原有实现)。"""
if provider is None:
provider = await get_default_provider()
provider = get_default_provider()
if model:
provider.model = model
version = prompt_version or "v1"
@@ -223,14 +153,13 @@ async def _predict_single(
logger.debug("predict cache hit match=%s", match_id)
return cached
# 1. 拼上下文(backtest/cutoff 防泄漏)
ctx = await build_context(match_id, backtest=backtest, cutoff_at=cutoff_at)
# 1. 拼上下文(P2-6: backtest 时使用 match_date-1天 作为 cutoff)
ctx = await build_context(match_id, backtest=backtest)
# 1.5 计算快照元数据(用于可复现性)
now = datetime.now(timezone.utc)
match_kickoff_at = ctx.match_dt
# 使用上下文实际计算的 cutoff(回测时可能为 match_dt-1天),而非开球时间
prediction_cutoff_at = ctx.cutoff if ctx.cutoff is not None else ctx.match_dt
prediction_cutoff_at = ctx.match_dt # 默认:比赛时间作为数据截止
input_hash = hashlib.sha256(ctx.text.encode("utf-8")).hexdigest()
# 2. 拼 prompt(指定版本)
@@ -243,17 +172,13 @@ async def _predict_single(
user=user_prompt,
json_mode=True,
temperature=0.3,
max_tokens=4096, # 推理模型的 reasoning 也计入输出 token,需留足余量
max_tokens=800,
)
if resp.error:
raise RuntimeError(f"LLM error: {resp.error}")
# P0-3: json_mode 下 parsed 为 None 说明 JSON 解析失败,不能 fallback 到 {}
if resp.parsed is None:
raise RuntimeError("LLM 输出 JSON 解析失败,parsed=None")
parsed = resp.parsed
parsed = resp.parsed or {}
# 3.5 严格校验 LLM 输出
from src.llm.validation import validate_prediction_output
@@ -269,33 +194,28 @@ async def _predict_single(
if m is None:
raise ValueError(f"match {match_id} not found")
pred = await _upsert_prediction(
session,
pred = Prediction(
match_id=match_id,
provider_name=settings.LLM_PROVIDER,
provider=settings.LLM_PROVIDER,
model=provider.model,
mode="single",
run_type="backtest" if backtest else "live",
values={
"prompt_version": version,
"prompt_tokens": resp.prompt_tokens,
"completion_tokens": resp.completion_tokens,
"latency_ms": resp.latency_ms,
"pred_home_goals": validated.pred_home_goals,
"pred_away_goals": validated.pred_away_goals,
"alt_pred_home_goals": validated.alt_pred_home_goals,
"alt_pred_away_goals": validated.alt_pred_away_goals,
"pred_1x2": validated.pred_1x2,
"subjective_confidence": validated.subjective_confidence,
"reasoning": validated.reasoning,
"raw_response": resp.raw,
"status": "success",
"match_kickoff_at": match_kickoff_at,
"prediction_cutoff_at": prediction_cutoff_at,
"prediction_created_at": now,
"input_hash": input_hash,
},
prompt_version=version,
prompt_tokens=resp.prompt_tokens,
completion_tokens=resp.completion_tokens,
latency_ms=resp.latency_ms,
pred_home_goals=validated.pred_home_goals,
pred_away_goals=validated.pred_away_goals,
pred_1x2=validated.pred_1x2,
subjective_confidence=validated.subjective_confidence,
reasoning=validated.reasoning,
raw_response=resp.raw,
status="success",
match_kickoff_at=match_kickoff_at,
prediction_cutoff_at=prediction_cutoff_at,
prediction_created_at=now,
input_hash=input_hash,
)
session.add(pred)
await session.refresh(pred)
result = PredictResult(
prediction_id=pred.id,
@@ -304,12 +224,9 @@ async def _predict_single(
prompt_version=version,
pred_home_goals=pred.pred_home_goals,
pred_away_goals=pred.pred_away_goals,
alt_pred_home_goals=pred.alt_pred_home_goals,
alt_pred_away_goals=pred.alt_pred_away_goals,
pred_1x2=pred.pred_1x2,
subjective_confidence=pred.subjective_confidence,
reasoning=pred.reasoning,
status=pred.status,
context=ctx.text,
latency_ms=resp.latency_ms,
raw=resp.raw,
@@ -318,10 +235,4 @@ async def _predict_single(
# 5. 写入缓存(仅当允许缓存时)
if use_cache:
_set_cached(match_id, settings.LLM_PROVIDER, provider.model, version, tpl_hash, result)
logger.info(
"预测完成 match=%s mode=%s status=%s pred=%s:%s (%s) latency=%sms",
match_id, "single", "success",
validated.pred_home_goals, validated.pred_away_goals, validated.pred_1x2,
resp.latency_ms,
)
return result
+3 -6
View File
@@ -9,8 +9,7 @@
裁决规则:
- 各报告的 confidence 和 data_sufficiency 是采信依据: no_data/error 状态的报告必须忽略,不得编造
- 5 专家: 近期状态分析专家 / 攻防数据分析专家 / 主客因素分析专家 / 阵容完整性分析专家 / 历史交锋分析专家
- 引用专家意见时使用上述全称,不要使用英文代码(form/stats/h2h 等)
- 5 专家维度: form(近期状态) / stats(攻防数据) / home_away(主客因素) / injuries(阵容完整性) / h2h(历史交锋)
- home_edge 是各专家的方向性判断(-1~1),冲突时给出你的权衡理由
- agent_weights 体现你对各报告的采信度(0-1,总和无须为 1)
- reasoning 需引用具体报告的证据
@@ -18,10 +17,8 @@
严格按此 JSON 输出,不要其他内容:
```json
{
"pred_home_goals": <int 0-10, 预测主队进球,必须是整数>,
"pred_away_goals": <int 0-10, 预测客队进球,必须是整数>,
"alt_pred_home_goals": <int 0-10, 备选比分主队进球(第二可能的比分,必须与主选不同)>,
"alt_pred_away_goals": <int 0-10, 备选比分客队进球>,
"pred_home_goals": <float, 预测主队进球>,
"pred_away_goals": <float, 预测客队进球>,
"1x2": "<'1'|'X'|'2'>",
"confidence": <0.0-1.0>,
"reasoning": "<250 字内推理,引用各报告证据>",
+21
View File
@@ -0,0 +1,21 @@
你是足球阵容完整性分析专家。分析以下两队的伤停与停赛信息,评估战力缺失程度。
{{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>"]
}
```

Some files were not shown because too many files have changed in this diff Show More