Compare commits
87
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7be14c518 | ||
|
|
983363dab7 | ||
|
|
6bdb1f8ae6 | ||
|
|
80616cf459 | ||
|
|
a24017eb69 | ||
|
|
3056a95ef4 | ||
|
|
d9aeff2114 | ||
|
|
17e99a4f9a | ||
|
|
4514ef4e92 | ||
|
|
18c89111d8 | ||
|
|
52d67863d6 | ||
|
|
5c7fdce0a3 | ||
|
|
3a9f3f5a0e | ||
|
|
f6c0145c32 | ||
|
|
b6a251407d | ||
|
|
b5f7e682f0 | ||
|
|
06e973d34d | ||
|
|
50a6753ff8 | ||
|
|
951faf31df | ||
|
|
11da4d3631 | ||
|
|
b9d7b6423e | ||
|
|
9ccda4bf51 | ||
|
|
8c87b9d024 | ||
|
|
f04e0df9e8 | ||
|
|
6a4994097a | ||
|
|
ae89d0f04f | ||
|
|
b6e367a640 | ||
|
|
675e176603 | ||
|
|
0a6af0657a | ||
|
|
a9e85a5d62 | ||
|
|
7a4e61ebc4 | ||
|
|
0a5a14cbbb | ||
|
|
1fc799a5b0 | ||
|
|
f08fef8a22 | ||
|
|
857a21910b | ||
|
|
ad184e1b82 | ||
|
|
83cc24e54b | ||
|
|
7025338226 | ||
|
|
4ae61a5683 | ||
|
|
a2dc2b07c5 | ||
|
|
930dfd184d | ||
|
|
9fb8e24be3 | ||
|
|
81b1f48382 | ||
|
|
730bc0de00 | ||
|
|
078fc0c5dd | ||
|
|
5cbfe84ae6 | ||
|
|
05dd0a6dc4 | ||
|
|
95be59bc9f | ||
|
|
8a8b71083e | ||
|
|
9a21edf5c0 | ||
|
|
130f41a31e | ||
|
|
d28203ac54 | ||
|
|
4cda662d6e | ||
|
|
9a65e81ab0 | ||
|
|
53b638d282 | ||
|
|
41109402a3 | ||
|
|
aaf9d38056 | ||
|
|
fb27f6e2d2 | ||
|
|
dd538d66d7 | ||
|
|
9d7f8dfeee | ||
|
|
acac856b61 | ||
|
|
19f17145cc | ||
|
|
c586a38b3a | ||
|
|
f52ec8b963 | ||
|
|
f05dc1ae15 | ||
|
|
ec8f36abb2 | ||
|
|
78375d6e39 | ||
|
|
7b1457f0db | ||
|
|
8e6ad5394e | ||
|
|
835d7217d0 | ||
|
|
c2c4752856 | ||
|
|
c0bcf1d851 | ||
|
|
6c24672e87 | ||
|
|
bee330f31f | ||
|
|
11efe91ce9 | ||
|
|
786f10aa11 | ||
|
|
b3e2c52b49 | ||
|
|
cf0751d1ff | ||
|
|
91e406f5ee | ||
|
|
6680da7d61 | ||
|
|
d3284c48c3 | ||
|
|
1219b4fd18 | ||
|
|
312778d995 | ||
|
|
e89ab1a0c9 | ||
|
|
ff0045ad93 | ||
|
|
983b620659 | ||
|
|
fa69795d69 |
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(python -m pytest tests/ -v)",
|
||||
"Bash(python -c \"import src.core.http_client; import src.llm.provider; import src.data.understat; import src.data.injuries; import src.llm.agents.orchestrator; import src.llm.context_builder; import src.api.app; print\\('All imports OK'\\)\")",
|
||||
"Bash(python -c \"from src.data.sources import get_source, list_sources; print\\('sources:', list_sources\\(\\)\\); print\\('bzzoiro:', get_source\\('bzzoiro'\\).name\\); print\\('understat:', get_source\\('understat'\\).name\\)\")",
|
||||
"Bash(python -c \"from src.api.routes.ingest import router; print\\('ingest router OK:', len\\(router.routes\\), 'routes'\\)\")",
|
||||
"Bash(python -c ' *)",
|
||||
"Bash(python -m pytest tests/ -q)",
|
||||
"Bash(git -C /p rev-parse --git-dir)",
|
||||
"Bash(git config *)",
|
||||
"Bash(git init *)",
|
||||
"Bash(git add *)",
|
||||
"Bash(git commit -m 'feat: 足球 LLM 预测服务初始提交 *)",
|
||||
"Bash(git remote *)",
|
||||
"Bash(git push *)",
|
||||
"Bash(git fetch *)",
|
||||
"Bash(git branch *)",
|
||||
"Bash(git commit *)",
|
||||
"Bash(pg_isready -h localhost -p 5432)",
|
||||
"Bash(python -c \"import pytest_asyncio; print\\('pytest-asyncio OK'\\)\")",
|
||||
"Bash(python -c \"import pytest; print\\('pytest', pytest.__version__\\)\")",
|
||||
"Bash(python -c \"import aiosqlite; print\\('aiosqlite', aiosqlite.__version__\\)\")",
|
||||
"Bash(npm run *)",
|
||||
"Bash(npm install *)",
|
||||
"Bash(git revert *)",
|
||||
"Bash(python -c \"from src.api.app import app; print\\('OK'\\)\")",
|
||||
"Bash(python -m pytest tests/test_agents.py::TestReportParsing::test_parse_bad_values_forgiving -v)",
|
||||
"Bash(python -c \"from src.data.bzzoiro import BzzoiroSource; print\\('OK'\\)\")",
|
||||
"Bash(python -m pytest tests/test_agents.py::TestReportParsing::test_parse_full_report -v)",
|
||||
"Bash(python -c \"from src.db.unit_of_work import UnitOfWork, get_uow; from src.db.repositories import MatchRepository, TeamRepository; print\\('OK'\\)\")",
|
||||
"Bash(python -c \"from src.data.bzzoiro import BzzoiroSource; from src.data.understat import UnderstatSource; from src.data.injuries import ingest_injuries; print\\('OK'\\)\")",
|
||||
"Bash(python -m compileall src tests)",
|
||||
"Bash(alembic current *)",
|
||||
"Bash(alembic history *)",
|
||||
"Bash(python -c \"from src.db.models import MatchStats, Prediction; print\\('Models OK'\\)\")",
|
||||
"Bash(git pull *)",
|
||||
"Bash(ocr delegate *)",
|
||||
"Bash(npm i *)",
|
||||
"Bash(python -c \"from src.llm.validation import KNOWN_AGENT_NAMES; print\\(KNOWN_AGENT_NAMES\\)\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
# 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
|
||||
+35
-3
@@ -1,8 +1,30 @@
|
||||
# ---- 应用 ----
|
||||
# 运行环境: 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 服务名
|
||||
DATABASE_URL=postgresql+asyncpg://football:football@localhost:5432/football
|
||||
|
||||
# ---- LLM (OpenAI-compatible,必填一个) ----
|
||||
@@ -18,12 +40,22 @@ LLM_TIMEOUT=60
|
||||
|
||||
# ---- 数据源 ----
|
||||
BZZOIRO_KEY=
|
||||
API_FOOTBALL_KEY=
|
||||
|
||||
# ---- CORS ----
|
||||
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||
|
||||
# ---- 加密主密钥 ----
|
||||
# 敏感配置(数据源/LLM API Key)入库加密与会话签名均由它派生。
|
||||
# 只存部署机 .env,切勿入库或提交;生成: openssl rand -base64 32
|
||||
# 变更后已加密配置无法解密,需在后台重新保存。
|
||||
SECRET_KEY=
|
||||
|
||||
# ---- 管理接口鉴权 ----
|
||||
# 采集/回测/回填接口的访问密钥(请求头 X-API-Key)。
|
||||
# 留空 = 不启用鉴权(本地开发默认);生产环境必须设置强随机值。
|
||||
# 管理后台登录密码(/admin 页面与采集/回测/回填等管理接口)。
|
||||
# 此处为初始值:启动时自动迁移为 scrypt 哈希入库,迁移后本行可删除。
|
||||
# 之后请在后台「系统配置」页修改密码。留空 = 不启用密码登录(本地开发默认)。
|
||||
ADMIN_PASSWORD=
|
||||
# 管理接口会话有效期(小时),默认 7 天
|
||||
ADMIN_SESSION_TTL_HOURS=168
|
||||
# 备选: 机器/脚本直接调接口用的密钥(请求头 X-API-Key),与密码二选一即可
|
||||
ADMIN_API_KEY=
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
.venv/
|
||||
.pytest_cache/
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
|
||||
# AI 助手上下文文件(不入库)
|
||||
CLAUDE.md
|
||||
docs/AGENTS.md
|
||||
docs/agents/
|
||||
|
||||
# 本地审查/预览脚手架(不入库)
|
||||
.tools/
|
||||
.preview/
|
||||
|
||||
# ===== ↑ 以上同步自 .gitignore(「从 .gitignore 同步」只重写以上部分)=====
|
||||
.git/
|
||||
.hg/
|
||||
.svn/
|
||||
node_modules/
|
||||
bower_components/
|
||||
jspm_packages/
|
||||
site-packages/
|
||||
venv/
|
||||
coverage/
|
||||
htmlcov/
|
||||
lcov-report/
|
||||
cmakefiles/
|
||||
cmake-build-*/
|
||||
bazel-*/
|
||||
pods/
|
||||
deriveddata/
|
||||
storybook-static/
|
||||
playwright-report/
|
||||
test-results/
|
||||
allure-results/
|
||||
allure-report/
|
||||
cdk.out/
|
||||
*.egg-info/
|
||||
*.dist-info/
|
||||
eggs/
|
||||
pip-wheel-metadata/
|
||||
wheels/
|
||||
# ----- ↑ 以上为 ZCode 默认排除规则(自定义规则请写在本行下方,不会被同步/恢复改动)-----
|
||||
# 自定义规则写在下方(本行提示可删除)
|
||||
+18
-4
@@ -2,12 +2,26 @@ FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN pip install --no-cache-dir hatchling
|
||||
COPY pyproject.toml README.md ./
|
||||
COPY src ./src
|
||||
# 直连官方源不稳定,固定使用清华 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
|
||||
|
||||
COPY src ./src
|
||||
RUN pip install --no-cache-dir .
|
||||
|
||||
# 将工作目录所有权移交给非 root 用户
|
||||
RUN chown -R profeto:profeto /app
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "src.api.app:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
# 以非 root 用户运行
|
||||
USER profeto
|
||||
|
||||
# 启动时先跑迁移,再启 uvicorn
|
||||
CMD ["sh", "-c", "alembic upgrade head && uvicorn src.api.app:app --host 0.0.0.0 --port 8000"]
|
||||
|
||||
@@ -12,28 +12,30 @@
|
||||
│ REST API
|
||||
┌──────────────────────▼──────────────────────────────┐
|
||||
│ FastAPI │
|
||||
│ ├── /api/v1/matches 比赛查询 │
|
||||
│ ├── /api/v1/matches 比赛查询(公开只读) │
|
||||
│ ├── /api/v1/predict LLM 预测 (单/多 Agent) │
|
||||
│ ├── /api/v1/ingest/* 数据采集 │
|
||||
│ ├── /api/v1/eval/* 评估回填 │
|
||||
│ └── /api/v1/backtest 回测 │
|
||||
│ ├── /api/v1/ingest/* 数据采集(需管理员) │
|
||||
│ ├── /api/v1/eval/* 评估回填(需管理员) │
|
||||
│ └── /api/v1/backtest 回测(需管理员) │
|
||||
└──────────┬─────────────────────────────┬────────────┘
|
||||
│ │
|
||||
┌──────────▼──────────┐ ┌─────────────▼────────────┐
|
||||
│ PostgreSQL │ │ LLM (OpenAI-compatible) │
|
||||
│ 6 张表 │ │ OpenAI / Deepseek / │
|
||||
│ 13 张表 │ │ OpenAI / Deepseek / │
|
||||
│ leagues/teams/ │ │ Ollama / 任意网关 │
|
||||
│ matches/match_ │ └──────────────────────────┘
|
||||
│ stats/predictions/ │
|
||||
│ injuries │
|
||||
│ stats/standings/ │
|
||||
│ predictions/ │
|
||||
│ app_settings/ │
|
||||
│ schedules + │
|
||||
│ raw_events 等 4 张 │
|
||||
│ 数据治理表 │
|
||||
└─────────────────────┘
|
||||
▲
|
||||
│ 采集
|
||||
┌──────────┴─────────────────────────────────────────┐
|
||||
│ 数据源 (DataSource 协议 + 注册表) │
|
||||
│ ├── bzzoiro 比分 / 统计 / xG │
|
||||
│ ├── understat xG 回填 │
|
||||
│ └── injuries 伤停数据 (api-football) │
|
||||
│ └── bzzoiro 比分 / 赛程 / 统计 / 积分榜 │
|
||||
└────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
@@ -55,7 +57,7 @@ API Route → Application Service → Repository → UnitOfWork → DB
|
||||
比赛数据 → 切片 ─┬─→ A 近期状态专家 ─┐
|
||||
├─→ B 攻防数据专家 ─┤
|
||||
├─→ C 主客因素专家 ─┼─→ 终裁专家 ─→ 最终预测
|
||||
├─→ D 阵容完整专家 ─┤
|
||||
├─→ D 联赛排名专家 ─┤
|
||||
└─→ E 历史交锋专家 ─┘
|
||||
```
|
||||
|
||||
@@ -66,8 +68,7 @@ API Route → Application Service → Repository → UnitOfWork → DB
|
||||
|
||||
### 数据正确性保障
|
||||
|
||||
- **Cutoff 机制**: 回测时只使用 `cutoff_at` 之前已采集的数据
|
||||
- **Injury 防泄漏**: 伤停查询强制 `retrieved_at <= cutoff`
|
||||
- **Cutoff 机制**: 回测时只使用 `cutoff_at` 之前已采集的数据(近况/交锋/统计/积分榜切片统一生效)
|
||||
- **LLM 输出校验**: Pydantic 严格校验 + 语义一致性检查
|
||||
- **数据库约束**: CHECK 约束作为最后一道防线
|
||||
|
||||
@@ -79,55 +80,106 @@ API Route → Application Service → Repository → UnitOfWork → DB
|
||||
- Docker (运行 PostgreSQL)
|
||||
- LLM API Key (OpenAI / Deepseek / Ollama 等)
|
||||
|
||||
### 1. 安装
|
||||
### 方式一:Docker Compose 部署(推荐)
|
||||
|
||||
```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. 启动全部服务(自动构建 + 执行迁移)
|
||||
docker compose up -d --build
|
||||
|
||||
# 4. 验证
|
||||
curl http://localhost:8000/health
|
||||
```
|
||||
|
||||
### 2. 启动数据库
|
||||
启动后访问:
|
||||
- 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
|
||||
docker compose up -d postgres
|
||||
alembic upgrade head # 首次运行需要执行迁移
|
||||
```
|
||||
|
||||
### 3. 启动服务
|
||||
# 4. 执行迁移
|
||||
alembic upgrade head
|
||||
|
||||
```bash
|
||||
# 后端 (终端 1)
|
||||
# 5. 启动后端 (终端 1)
|
||||
uvicorn src.api.app:app --reload
|
||||
|
||||
# 前端 (终端 2)
|
||||
# 6. 启动前端 (终端 2)
|
||||
cd frontend && npm install && npm run dev
|
||||
```
|
||||
|
||||
后端运行在 `http://localhost:8000`,前端在 `http://localhost:5173`。
|
||||
|
||||
## 生产上线检查清单
|
||||
|
||||
公网部署前逐项确认(第 1–4 项由启动校验强制,不满足拒绝启动;详见 [docs/06-deployment.md](docs/06-deployment.md#生产上线检查清单)):
|
||||
|
||||
- [ ] `APP_ENV=production`(安全校验 / Cookie `Secure` / 管理端点 fail-closed 的总开关)
|
||||
- [ ] `SECRET_KEY` 强随机:`openssl rand -base64 32`,禁止弱值
|
||||
- [ ] `ADMIN_PASSWORD` 或 `ADMIN_API_KEY` 至少配置其一
|
||||
- [ ] 数据库强密码,禁止 `football:football` 等示例弱密码
|
||||
- [ ] HTTPS(反代终结 TLS;production 下会话 Cookie 自动 `Secure`)
|
||||
- [ ] 反代后设 `TRUST_PROXY_HEADERS=True`,仅可信反代可达 API,并配置 `X-Forwarded-For` / `X-Real-IP`
|
||||
- [ ] 限流前置到 Nginx `limit_req`;应用内限流与 KeyRing 仅单进程有效,多 worker 会放大配额
|
||||
- [ ] uvicorn 单 worker(默认);需扩容先网关统一限流再起多实例
|
||||
- [ ] 启动后验证 `/health` 与 `/health/ready` 均 200
|
||||
- [ ] 数据库迁移已内置:compose/Dockerfile 启动即执行 `alembic upgrade head`
|
||||
|
||||
## 安全与限流
|
||||
|
||||
- `/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 概览
|
||||
|
||||
**公开只读**(无需登录;`predict` 带内存限流):
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | `/api/v1/matches` | 比赛查询(筛选/分页) |
|
||||
| GET | `/api/v1/leagues` | 联赛列表 |
|
||||
| POST | `/api/v1/predict` | LLM 预测 (`mode=single`/`multi`) |
|
||||
| GET | `/api/v1/predictions` | 预测历史 |
|
||||
| POST | `/api/v1/ingest/bzzoiro` | 采集比分/统计 |
|
||||
| POST | `/api/v1/ingest/understat` | 回填 xG |
|
||||
| POST | `/api/v1/ingest/injuries` | 采集伤停 |
|
||||
| GET | `/api/v1/leagues` | 联赛列表(仅 id/code/name/country) |
|
||||
| GET | `/api/v1/matches` | 比赛查询(筛选/游标分页) |
|
||||
| GET | `/api/v1/matches/{id}` | 比赛详情(含统计与最近预测) |
|
||||
| GET | `/api/v1/matches/{id}/context` | 比赛上下文(双方近况 + 历史交锋) |
|
||||
| GET | `/api/v1/standings` | 联赛积分榜 |
|
||||
| POST | `/api/v1/predict` | LLM 预测 (`mode=single`/`multi`/`baseline`) |
|
||||
| GET | `/health`、`/health/ready` | 存活 / 就绪检查(含 DB) |
|
||||
|
||||
**需管理员**(Cookie 会话或 `X-API-Key`):
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| POST | `/api/v1/ingest/bzzoiro` | 采集赛果/赛程/统计/积分榜 |
|
||||
| GET | `/api/v1/predictions` | 预测历史(列表) |
|
||||
| GET | `/api/v1/predictions/{id}` | 单条预测详情 |
|
||||
| POST | `/api/v1/eval/settle` | 回填实际结果 |
|
||||
| GET | `/api/v1/eval/summary` | 准确率汇总 |
|
||||
| POST | `/api/v1/backtest` | 历史回测 |
|
||||
| GET | `/health` | 存活检查 |
|
||||
| GET | `/health/ready` | 就绪检查(含 DB) |
|
||||
| `/api/v1/admin/**` | 配置/采集状态/日志/定时任务/死信等 | 管理后台(router 级鉴权) |
|
||||
|
||||
## 项目结构
|
||||
|
||||
@@ -136,36 +188,46 @@ Profeto/
|
||||
├── src/
|
||||
│ ├── api/ # FastAPI 路由层
|
||||
│ │ ├── app.py # 应用工厂 + lifespan
|
||||
│ │ ├── deps.py # 依赖注入:鉴权 / 限流
|
||||
│ │ ├── schemas.py # Pydantic 请求/响应模型
|
||||
│ │ └── routes/
|
||||
│ │ ├── matches.py # 比赛查询
|
||||
│ │ ├── predict.py # 预测入口
|
||||
│ │ ├── ingest.py # 数据采集
|
||||
│ │ ├── eval.py # 评估回填
|
||||
│ │ └── backtest.py # 回测
|
||||
│ │ ├── matches.py # 比赛查询(公开只读)
|
||||
│ │ ├── predict.py # 预测入口 + 预测历史
|
||||
│ │ ├── ingest.py # 数据采集(需管理员)
|
||||
│ │ ├── eval.py # 评估回填(需管理员)
|
||||
│ │ ├── backtest.py # 回测(需管理员)
|
||||
│ │ ├── auth.py # 登录/登出/改密
|
||||
│ │ ├── admin_settings.py # /admin/** 配置/日志/数据质量(router 级鉴权)
|
||||
│ │ └── schedules.py # 定时任务 + 死信重试(router 级鉴权)
|
||||
│ ├── core/ # 基础设施
|
||||
│ │ ├── config.py # pydantic-settings 配置
|
||||
│ │ ├── crypto.py # 加密/哈希
|
||||
│ │ ├── http_client.py # 共享 httpx 客户端
|
||||
│ │ └── retry.py # 重试工具(指数退避)
|
||||
│ │ ├── log_buffer.py # 内存日志缓冲(admin 日志页)
|
||||
│ │ ├── runtime_config.py # DB 配置覆盖(.env → app_settings)
|
||||
│ │ ├── scheduler.py # 进程内 cron 调度器
|
||||
│ │ └── security_check.py # 启动安全校验
|
||||
│ ├── data/ # 数据层
|
||||
│ │ ├── sources.py # DataSource 协议 + 注册表
|
||||
│ │ ├── bzzoiro.py # bzzoiro 数据源(events/standings/stats)
|
||||
│ │ ├── normalize.py # 数据规范化契约
|
||||
│ │ ├── bzzoiro.py # bzzoiro 数据源
|
||||
│ │ ├── understat.py # understat xG 数据源
|
||||
│ │ ├── injuries.py # 伤停数据
|
||||
│ │ ├── config.py # 联赛映射常量
|
||||
│ │ └── team_names.py # 队名归一化
|
||||
│ │ ├── key_ring.py # API Key 轮换环(429 冷却)
|
||||
│ │ ├── team_names.py # 队名归一化
|
||||
│ │ └── team_names_zh.py # 队名中文映射
|
||||
│ ├── db/ # 数据库
|
||||
│ │ ├── base.py # SQLAlchemy async engine
|
||||
│ │ ├── models.py # ORM 模型 (6 表)
|
||||
│ │ ├── models.py # ORM 模型 (12 表)
|
||||
│ │ ├── unit_of_work.py # UnitOfWork 事务封装
|
||||
│ │ └── repositories.py # Repository 数据访问
|
||||
│ └── llm/ # LLM 预测核心
|
||||
│ ├── predict.py # 预测服务 (缓存 + 单/多模式)
|
||||
│ ├── predict.py # 预测服务 (缓存 + 单/多/基线模式)
|
||||
│ ├── context_builder.py # 数据切片 + 上下文拼接
|
||||
│ ├── baseline.py # 基线预测(均值模型)
|
||||
│ ├── eval.py # 评估统计
|
||||
│ ├── backtest.py # 回测框架
|
||||
│ ├── provider.py # 多提供商 LLM 抽象
|
||||
│ ├── utils.py # LLM 工具函数
|
||||
│ ├── validation.py # LLM 输出校验
|
||||
│ ├── agents/
|
||||
│ │ ├── base.py # Agent 基础设施 + 解析
|
||||
@@ -173,6 +235,11 @@ Profeto/
|
||||
│ └── prompts/ # Prompt 模板
|
||||
├── alembic/ # 数据库迁移
|
||||
├── frontend/ # React 前端
|
||||
│ └── src/
|
||||
│ ├── pages/ # 公开站(赛程 Matches + 积分榜 Standings)
|
||||
│ ├── admin/ # 管理后台(布局/页面/数据访问层 dal.ts)
|
||||
│ ├── components/ # 共享组件
|
||||
│ └── lib/http.ts # 唯一 HTTP 实现(带凭据/超时/错误处理)
|
||||
├── docs/ # 详细文档
|
||||
├── tests/ # 单元测试
|
||||
├── docker-compose.yml
|
||||
@@ -207,7 +274,6 @@ Profeto/
|
||||
| `LLM_SPECIALIST_MODEL` | 专家模型 (空=回落 LLM_MODEL) | |
|
||||
| `LLM_AGGREGATOR_MODEL` | 终裁模型 (空=回落 LLM_MODEL) | |
|
||||
| `BZZOIRO_KEY` | bzzoiro API Key | *(必填)* |
|
||||
| `API_FOOTBALL_KEY` | api-football Key (伤停) | |
|
||||
| `CORS_ORIGINS` | 允许的跨域来源 | `http://localhost:5173` |
|
||||
|
||||
## 测试
|
||||
|
||||
+24
-1
@@ -3,7 +3,7 @@ from logging.config import fileConfig
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
from sqlalchemy import engine_from_config, pool, text
|
||||
from alembic import context
|
||||
|
||||
# 把项目根加入 pythonpath,让 alembic 能找到 src 包
|
||||
@@ -29,6 +29,23 @@ if config.config_file_name is not None:
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def _ensure_version_table(connection):
|
||||
"""确保 alembic_version 表存在且 version_num 列足够长。
|
||||
|
||||
Alembic 默认 version_num 是 String(32),但我们的迁移名较长(如
|
||||
0005_prediction_status_and_stats_provenance 有 41 个字符),会导致
|
||||
StringDataRightTruncation 错误。
|
||||
"""
|
||||
result = connection.execute(text(
|
||||
"SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'alembic_version')"
|
||||
))
|
||||
if not result.scalar():
|
||||
connection.execute(text(
|
||||
"CREATE TABLE alembic_version (version_num VARCHAR(255) NOT NULL PRIMARY KEY)"
|
||||
))
|
||||
connection.commit()
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode."""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
@@ -52,6 +69,12 @@ def run_migrations_online() -> None:
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
_ensure_version_table(connection)
|
||||
# SQLAlchemy 2.0 autobegin:上面的探测 SELECT 会留下隐式事务。
|
||||
# 若不结束,alembic(>=1.16)会判定处于「外部事务」而全程不提交,
|
||||
# 迁移在连接关闭时被静默回滚(upgrade 退出码仍为 0)。
|
||||
if connection.in_transaction():
|
||||
connection.commit()
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
|
||||
with context.begin_transaction():
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""修复 injuries 表约束命名与 ORM 声明不一致
|
||||
|
||||
Revision ID: 0007_injuries_constraint_naming_align
|
||||
Revises: 0006_schema_model_drift_cleanup
|
||||
Create Date: 2026-09-16
|
||||
|
||||
背景(见代码审查报告 P2-5):
|
||||
0003 迁移使用 sa.UniqueConstraint 创建唯一约束,
|
||||
而 ORM models.py 中声明为 Index(..., unique=True)。
|
||||
虽然 PostgreSQL 中两者效果相同(都保证唯一性),
|
||||
但 pg_catalog 中表示不同,会导致:
|
||||
- alembic autogenerate 持续报告漂移
|
||||
- 约束命名约定不一致(uc_ 前缀 vs ix_ 前缀)
|
||||
|
||||
本迁移将 UniqueConstraint 替换为唯一索引,与 ORM 声明对齐。
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '0007_injuries_constraint_naming_align'
|
||||
down_revision: Union[str, None] = '0006_schema_model_drift_cleanup'
|
||||
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)
|
||||
|
||||
# 检查当前约束类型
|
||||
constraints = {
|
||||
c["name"]: c
|
||||
for c in inspector.get_unique_constraints("injuries")
|
||||
}
|
||||
indexes = {
|
||||
i["name"]: i
|
||||
for i in inspector.get_indexes("injuries")
|
||||
}
|
||||
|
||||
# 如果存在 UniqueConstraint 形式的 ix_injuries_player_fixture,替换为唯一索引
|
||||
if "ix_injuries_player_fixture" in constraints:
|
||||
# 删除唯一约束
|
||||
op.drop_constraint("ix_injuries_player_fixture", "injuries", type_="unique")
|
||||
|
||||
# 如果不存在同名唯一索引,创建它(与 ORM 声明一致)
|
||||
if "ix_injuries_player_fixture" not in indexes:
|
||||
op.create_index(
|
||||
"ix_injuries_player_fixture",
|
||||
"injuries",
|
||||
["player_id", "fixture_id", "injury_type"],
|
||||
unique=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
indexes = {
|
||||
i["name"]: i
|
||||
for i in inspector.get_indexes("injuries")
|
||||
}
|
||||
constraints = {
|
||||
c["name"]: c
|
||||
for c in inspector.get_unique_constraints("injuries")
|
||||
}
|
||||
|
||||
# 恢复为 UniqueConstraint 形式
|
||||
if "ix_injuries_player_fixture" in indexes:
|
||||
op.drop_index("ix_injuries_player_fixture", table_name="injuries")
|
||||
|
||||
if "ix_injuries_player_fixture" not in constraints:
|
||||
op.create_unique_constraint(
|
||||
"ix_injuries_player_fixture",
|
||||
"injuries",
|
||||
["player_id", "fixture_id", "injury_type"],
|
||||
)
|
||||
@@ -0,0 +1,59 @@
|
||||
"""为 predictions 表添加 match_id+provider+model 唯一约束
|
||||
|
||||
Revision ID: 0007_predictions_unique_constraint
|
||||
Revises: 0007_injuries_constraint_naming_align
|
||||
Create Date: 2026-09-16
|
||||
|
||||
背景(见代码审查报告 P1-6):
|
||||
同一 match_id + provider + model 组合不应产生重复预测。
|
||||
当前缺少数据库级唯一约束,回测多次运行或并发采集可能产生重复记录,
|
||||
导致统计偏差。
|
||||
|
||||
先清理已存在的重复记录(保留最早创建的那条),再添加唯一约束。
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '0007_predictions_unique_constraint'
|
||||
down_revision: Union[str, None] = '0007_injuries_constraint_naming_align'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. 清理已存在的重复记录(保留 id 最小的)
|
||||
op.execute(
|
||||
"""
|
||||
DELETE FROM predictions
|
||||
WHERE id NOT IN (
|
||||
SELECT MIN(id)
|
||||
FROM predictions
|
||||
GROUP BY match_id, provider, model
|
||||
)
|
||||
AND match_id IN (
|
||||
SELECT match_id
|
||||
FROM predictions
|
||||
GROUP BY match_id, provider, model
|
||||
HAVING COUNT(*) > 1
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# 2. 添加唯一约束
|
||||
op.create_unique_constraint(
|
||||
"uq_predictions_match_provider_model",
|
||||
"predictions",
|
||||
["match_id", "provider", "model"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint(
|
||||
"uq_predictions_match_provider_model",
|
||||
"predictions",
|
||||
type_="unique",
|
||||
)
|
||||
@@ -0,0 +1,107 @@
|
||||
"""创建 Bronze 层、死信表、质量监控、血缘追踪 4 张新表
|
||||
|
||||
Revision ID: 0008_raw_event_and_ingest_failure
|
||||
Revises: 0007_predictions_unique_constraint
|
||||
Create Date: 2026-09-17
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
revision: str = '0008_raw_event_and_ingest_failure'
|
||||
down_revision: Union[str, None] = '0007_predictions_unique_constraint'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. RawEvent - Bronze 层原始事件存档
|
||||
op.create_table(
|
||||
'raw_events',
|
||||
sa.Column('id', sa.BigInteger(), primary_key=True),
|
||||
sa.Column('source_system', sa.String(50), nullable=False),
|
||||
sa.Column('source_record_id', sa.String(100), nullable=False),
|
||||
sa.Column('raw_payload', JSONB(), nullable=False),
|
||||
sa.Column('ingested_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column('ingest_batch_id', sa.String(36), nullable=True),
|
||||
sa.UniqueConstraint('source_system', 'source_record_id', name='uq_raw_event'),
|
||||
)
|
||||
op.create_index('ix_raw_event_batch', 'raw_events', ['ingest_batch_id'])
|
||||
|
||||
# 2. IngestFailure - 采集失败死信表
|
||||
op.create_table(
|
||||
'ingest_failures',
|
||||
sa.Column('id', sa.BigInteger(), primary_key=True),
|
||||
sa.Column('source_system', sa.String(50), nullable=False),
|
||||
sa.Column('entity_type', sa.String(50), nullable=False),
|
||||
sa.Column('source_record_id', sa.String(100), nullable=True),
|
||||
sa.Column('error_type', sa.String(50), nullable=False),
|
||||
sa.Column('error_detail', sa.Text(), nullable=True),
|
||||
sa.Column('raw_payload', JSONB(), nullable=True),
|
||||
sa.Column('retry_count', sa.Integer(), server_default='0'),
|
||||
sa.Column('next_retry_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('status', sa.String(20), server_default='pending'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column('resolved_at', sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.create_index('ix_ingest_failure_status', 'ingest_failures', ['status', 'next_retry_at'])
|
||||
op.create_check_constraint(
|
||||
'ck_ingest_failure_status',
|
||||
'ingest_failures',
|
||||
"status IN ('pending', 'retrying', 'resolved', 'abandoned')",
|
||||
)
|
||||
|
||||
# 3. DataQualityCheck - 数据质量监控
|
||||
op.create_table(
|
||||
'data_quality_checks',
|
||||
sa.Column('id', sa.BigInteger(), primary_key=True),
|
||||
sa.Column('check_name', sa.String(100), nullable=False),
|
||||
sa.Column('entity_type', sa.String(50), nullable=False),
|
||||
sa.Column('entity_id', sa.Integer(), nullable=True),
|
||||
sa.Column('expected_value', sa.Float(), nullable=True),
|
||||
sa.Column('actual_value', sa.Float(), nullable=False),
|
||||
sa.Column('passed', sa.Boolean(), nullable=False),
|
||||
sa.Column('severity', sa.String(10), server_default='warning'),
|
||||
sa.Column('detail', JSONB(), nullable=True),
|
||||
sa.Column('checked_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index('ix_dqc_checked_at', 'data_quality_checks', ['checked_at'])
|
||||
op.create_index('ix_dqc_entity', 'data_quality_checks', ['entity_type', 'entity_id'])
|
||||
|
||||
# 4. DataLineage - ETL 血缘追踪
|
||||
op.create_table(
|
||||
'data_lineage',
|
||||
sa.Column('id', sa.BigInteger(), primary_key=True),
|
||||
sa.Column('source_system', sa.String(50), nullable=False),
|
||||
sa.Column('source_record_id', sa.String(100), nullable=False),
|
||||
sa.Column('target_table', sa.String(50), nullable=False),
|
||||
sa.Column('target_id', sa.Integer(), nullable=True),
|
||||
sa.Column('transform_name', sa.String(50), nullable=False),
|
||||
sa.Column('transform_detail', JSONB(), nullable=True),
|
||||
sa.Column('batch_id', sa.String(36), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index('ix_lineage_source', 'data_lineage', ['source_system', 'source_record_id'])
|
||||
op.create_index('ix_lineage_target', 'data_lineage', ['target_table', 'target_id'])
|
||||
op.create_index('ix_lineage_batch', 'data_lineage', ['batch_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_lineage_batch', table_name='data_lineage')
|
||||
op.drop_index('ix_lineage_target', table_name='data_lineage')
|
||||
op.drop_index('ix_lineage_source', table_name='data_lineage')
|
||||
op.drop_table('data_lineage')
|
||||
|
||||
op.drop_index('ix_dqc_entity', table_name='data_quality_checks')
|
||||
op.drop_index('ix_dqc_checked_at', table_name='data_quality_checks')
|
||||
op.drop_table('data_quality_checks')
|
||||
|
||||
op.drop_index('ix_ingest_failure_status', table_name='ingest_failures')
|
||||
op.drop_table('ingest_failures')
|
||||
|
||||
op.drop_index('ix_raw_event_batch', table_name='raw_events')
|
||||
op.drop_table('raw_events')
|
||||
@@ -0,0 +1,29 @@
|
||||
"""为 MatchStats 添加 xG 追踪字段
|
||||
|
||||
Revision ID: 0009_match_stats_xg_fields
|
||||
Revises: 0008_raw_event_and_ingest_failure
|
||||
Create Date: 2026-09-17
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '0009_match_stats_xg_fields'
|
||||
down_revision: Union[str, None] = '0008_raw_event_and_ingest_failure'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('match_stats', sa.Column('xg_source', sa.String(30), nullable=True))
|
||||
op.add_column('match_stats', sa.Column('xg_updated_at', sa.DateTime(timezone=True), nullable=True))
|
||||
op.add_column('match_stats', sa.Column('xg_source_record_id', sa.String(100), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('match_stats', 'xg_source_record_id')
|
||||
op.drop_column('match_stats', 'xg_updated_at')
|
||||
op.drop_column('match_stats', 'xg_source')
|
||||
@@ -0,0 +1,30 @@
|
||||
"""新增 app_settings 表(后台运行时配置)
|
||||
|
||||
Revision ID: 0010_app_settings
|
||||
Revises: 0009_match_stats_xg_fields
|
||||
Create Date: 2026-09-18
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '0010_app_settings'
|
||||
down_revision: Union[str, None] = '0009_match_stats_xg_fields'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'app_settings',
|
||||
sa.Column('key', sa.String(100), primary_key=True),
|
||||
sa.Column('value', sa.Text(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('app_settings')
|
||||
@@ -0,0 +1,27 @@
|
||||
"""predictions 增加备选比分字段
|
||||
|
||||
Revision ID: 0011_prediction_alt_scores
|
||||
Revises: 0010_app_settings
|
||||
Create Date: 2026-09-19
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '0011_prediction_alt_scores'
|
||||
down_revision: Union[str, None] = '0010_app_settings'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('predictions', sa.Column('alt_pred_home_goals', sa.Integer(), nullable=True))
|
||||
op.add_column('predictions', sa.Column('alt_pred_away_goals', sa.Integer(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('predictions', 'alt_pred_away_goals')
|
||||
op.drop_column('predictions', 'alt_pred_home_goals')
|
||||
@@ -0,0 +1,65 @@
|
||||
"""修复 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,
|
||||
)
|
||||
@@ -0,0 +1,74 @@
|
||||
"""修复预测唯一约束过粗,增加 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")
|
||||
@@ -0,0 +1,39 @@
|
||||
"""为 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")
|
||||
@@ -0,0 +1,102 @@
|
||||
"""bzzoiro 单一数据源重构:新增 standings、扩展 match_stats、删除 injuries
|
||||
|
||||
Revision ID: 0015_bzzoiro_single_source
|
||||
Revises: 0014_predictions_agent_weights
|
||||
Create Date: 2026-09-20
|
||||
|
||||
变更内容:
|
||||
1. 新增 standings 表(联赛积分榜快照,来源 bzzoiro /leagues/{id}/standings/)
|
||||
2. match_stats 新增 bzzoiro /events/{id}/stats/ 扩展字段:
|
||||
home/away_big_chances, home/away_fouls
|
||||
3. 删除 injuries 表(数据源已下线,不再采集伤停)
|
||||
4. Understat 无独立表(xG 写入 match_stats),无需删表;
|
||||
历史 source='understat' 数据保留,不再新增。
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '0015_bzzoiro_single_source'
|
||||
down_revision: Union[str, None] = '0014_predictions_agent_weights'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 0. matches 增加数据血缘列(bzzoiro 上游事件 ID)
|
||||
op.add_column('matches', sa.Column('source_event_id', sa.BigInteger()))
|
||||
op.create_index('ix_matches_source_event_id', 'matches', ['source_event_id'])
|
||||
|
||||
# 1. standings 表
|
||||
op.create_table(
|
||||
'standings',
|
||||
sa.Column('id', sa.Integer(), primary_key=True),
|
||||
sa.Column('league_id', sa.Integer(), sa.ForeignKey('leagues.id'), nullable=False),
|
||||
sa.Column('season', sa.String(12), nullable=False),
|
||||
sa.Column('team_id', sa.Integer(), sa.ForeignKey('teams.id'), nullable=False),
|
||||
sa.Column('position', sa.Integer(), nullable=False),
|
||||
sa.Column('played', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('won', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('drawn', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('lost', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('goals_for', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('goals_against', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('goal_diff', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('points', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('xg_for', sa.Float()),
|
||||
sa.Column('xg_against', sa.Float()),
|
||||
sa.Column('form', sa.String(20)),
|
||||
sa.Column('zone', sa.String(50)),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True)),
|
||||
sa.Column('retrieved_at', sa.DateTime(timezone=True)),
|
||||
sa.UniqueConstraint('league_id', 'season', 'team_id', name='uq_standings_league_season_team'),
|
||||
)
|
||||
op.create_index(
|
||||
'ix_standings_league_season_pos', 'standings',
|
||||
['league_id', 'season', 'position'],
|
||||
)
|
||||
|
||||
# 2. match_stats 扩展字段
|
||||
op.add_column('match_stats', sa.Column('home_big_chances', sa.Integer()))
|
||||
op.add_column('match_stats', sa.Column('away_big_chances', sa.Integer()))
|
||||
op.add_column('match_stats', sa.Column('home_fouls', sa.Integer()))
|
||||
op.add_column('match_stats', sa.Column('away_fouls', sa.Integer()))
|
||||
|
||||
# 3. 删除 injuries 表
|
||||
op.drop_index('ix_injuries_player_fixture', table_name='injuries')
|
||||
op.drop_index('ix_injuries_team_date', table_name='injuries')
|
||||
op.drop_table('injuries')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 恢复 injuries 表(不含数据)
|
||||
op.create_table(
|
||||
'injuries',
|
||||
sa.Column('id', sa.Integer(), primary_key=True),
|
||||
sa.Column('player_id', sa.Integer()),
|
||||
sa.Column('player_name', sa.String(120), nullable=False),
|
||||
sa.Column('team_id', sa.Integer(), sa.ForeignKey('teams.id')),
|
||||
sa.Column('fixture_id', sa.Integer()),
|
||||
sa.Column('league_id', sa.Integer()),
|
||||
sa.Column('injury_type', sa.String(50)),
|
||||
sa.Column('reason', sa.String(200)),
|
||||
sa.Column('injury_date', sa.Date()),
|
||||
sa.Column('return_date', sa.Date()),
|
||||
sa.Column('retrieved_at', sa.DateTime(timezone=True)),
|
||||
)
|
||||
op.create_index('ix_injuries_player_fixture', 'injuries', ['player_id', 'fixture_id', 'injury_type'])
|
||||
op.create_index('ix_injuries_team_date', 'injuries', ['team_id', 'injury_date'])
|
||||
|
||||
op.drop_index('ix_matches_source_event_id', table_name='matches')
|
||||
op.drop_column('matches', 'source_event_id')
|
||||
|
||||
op.drop_column('match_stats', 'away_fouls')
|
||||
op.drop_column('match_stats', 'home_fouls')
|
||||
op.drop_column('match_stats', 'away_big_chances')
|
||||
op.drop_column('match_stats', 'home_big_chances')
|
||||
|
||||
op.drop_index('ix_standings_league_season_pos', table_name='standings')
|
||||
op.drop_table('standings')
|
||||
@@ -0,0 +1,38 @@
|
||||
"""新增 schedules 表
|
||||
|
||||
Revision ID: 0016_schedules
|
||||
Revises: 0015_bzzoiro_single_source
|
||||
Create Date: 2026-09-21
|
||||
|
||||
定时采集任务配置表。
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '0016_schedules'
|
||||
down_revision: Union[str, None] = '0015_bzzoiro_single_source'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'schedules',
|
||||
sa.Column('id', sa.String(50), primary_key=True),
|
||||
sa.Column('task', sa.String(20), nullable=False),
|
||||
sa.Column('cron', sa.String(100), nullable=False),
|
||||
sa.Column('leagues', sa.Text()),
|
||||
sa.Column('enabled', sa.Boolean(), server_default='true'),
|
||||
sa.Column('last_run_at', sa.DateTime(timezone=True)),
|
||||
sa.Column('last_status', sa.String(20)),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True)),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True)),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('schedules')
|
||||
@@ -0,0 +1,39 @@
|
||||
"""扩展 ck_mode_enum 约束支持 baseline 模式
|
||||
|
||||
Revision ID: 0017_mode_baseline
|
||||
Revises: 0016_schedules
|
||||
Create Date: 2026-09-21
|
||||
|
||||
Code Review High-1/2 修复:
|
||||
业务支持 mode='baseline'(非 LLM 统计基线),但 DB CHECK 约束只允许
|
||||
('single', 'multi'),导致 baseline 预测写入时 CheckViolation。
|
||||
run_type='baseline' 改为 'live'(代码侧),约束无需扩展。
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '0017_mode_baseline'
|
||||
down_revision: Union[str, None] = '0016_schedules'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_constraint('ck_mode_enum', 'predictions', type_='check')
|
||||
op.create_check_constraint(
|
||||
'ck_mode_enum', 'predictions',
|
||||
"mode IN ('single', 'multi', 'baseline')",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 先清理 baseline 数据再恢复旧约束
|
||||
op.execute("DELETE FROM predictions WHERE mode = 'baseline'")
|
||||
op.drop_constraint('ck_mode_enum', 'predictions', type_='check')
|
||||
op.create_check_constraint(
|
||||
'ck_mode_enum', 'predictions',
|
||||
"mode IN ('single', 'multi')",
|
||||
)
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Match 表补充 CHECK 约束:完赛必须有比分 + 状态枚举 + 半场≤全场
|
||||
|
||||
Revision ID: 0018_match_checks
|
||||
Revises: 0017_mode_baseline
|
||||
Create Date: 2026-09-21
|
||||
|
||||
Code Review DB-5:
|
||||
- 已完赛比赛必须有比分(数据库级兜底)
|
||||
- match_status 枚举约束
|
||||
- 半场进球 ≤ 全场进球
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '0018_match_checks'
|
||||
down_revision: Union[str, None] = '0017_mode_baseline'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 先清理可能违反新约束的数据
|
||||
op.execute("UPDATE matches SET match_status = 'scheduled' WHERE match_status NOT IN ('finished', 'scheduled', 'in_play', 'paused', 'postponed', 'cancelled', 'suspended')")
|
||||
op.execute("UPDATE matches SET home_goals = 0, away_goals = 0 WHERE match_status = 'finished' AND (home_goals IS NULL OR away_goals IS NULL)")
|
||||
|
||||
# 添加 CHECK 约束
|
||||
op.create_check_constraint(
|
||||
'ck_matches_finished_has_score', 'matches',
|
||||
"match_status <> 'finished' OR (home_goals IS NOT NULL AND away_goals IS NOT NULL)",
|
||||
)
|
||||
op.create_check_constraint(
|
||||
'ck_matches_status_enum', 'matches',
|
||||
"match_status IN ('finished', 'scheduled', 'in_play', 'paused', 'postponed', 'cancelled', 'suspended')",
|
||||
)
|
||||
op.create_check_constraint(
|
||||
'ck_matches_home_ht_le_full', 'matches',
|
||||
"home_ht_goals IS NULL OR home_goals IS NULL OR home_ht_goals <= home_goals",
|
||||
)
|
||||
op.create_check_constraint(
|
||||
'ck_matches_away_ht_le_full', 'matches',
|
||||
"away_ht_goals IS NULL OR away_goals IS NULL OR away_ht_goals <= away_goals",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint('ck_matches_away_ht_le_full', 'matches', type_='check')
|
||||
op.drop_constraint('ck_matches_home_ht_le_full', 'matches', type_='check')
|
||||
op.drop_constraint('ck_matches_status_enum', 'matches', type_='check')
|
||||
op.drop_constraint('ck_matches_finished_has_score', 'matches', type_='check')
|
||||
@@ -0,0 +1,46 @@
|
||||
"""新增 ingest_jobs 表
|
||||
|
||||
Revision ID: 0019_ingest_jobs
|
||||
Revises: 0018_match_checks
|
||||
Create Date: 2026-09-21
|
||||
|
||||
采集任务状态跟踪:POST /ingest/bzzoiro 创建 pending job 后台执行,
|
||||
解决 fire-and-forget 不可观测问题(admin 可查询任务级状态与结果摘要)。
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '0019_ingest_jobs'
|
||||
down_revision: Union[str, None] = '0018_match_checks'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'ingest_jobs',
|
||||
sa.Column('id', sa.String(36), primary_key=True),
|
||||
sa.Column('task', sa.String(20), nullable=False),
|
||||
sa.Column('params', JSONB, nullable=False),
|
||||
sa.Column('status', sa.String(20), nullable=False, server_default='pending'),
|
||||
sa.Column('result', JSONB),
|
||||
sa.Column('error', sa.Text()),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True)),
|
||||
sa.Column('started_at', sa.DateTime(timezone=True)),
|
||||
sa.Column('finished_at', sa.DateTime(timezone=True)),
|
||||
sa.CheckConstraint(
|
||||
"status IN ('pending','running','success','failed')",
|
||||
name='ck_ingest_jobs_status',
|
||||
),
|
||||
)
|
||||
op.create_index('ix_ingest_jobs_created', 'ingest_jobs', ['created_at'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_ingest_jobs_created', table_name='ingest_jobs')
|
||||
op.drop_table('ingest_jobs')
|
||||
+35
-7
@@ -2,30 +2,58 @@ services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: football
|
||||
POSTGRES_PASSWORD: football
|
||||
POSTGRES_DB: football
|
||||
POSTGRES_USER: ${POSTGRES_USER:?POSTGRES_USER 未设置}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD 未设置}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-football}
|
||||
ports:
|
||||
- "5432:5432"
|
||||
- "${POSTGRES_PORT:-5433}:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U football"]
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:?POSTGRES_USER 未设置}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
api:
|
||||
build: .
|
||||
command: uvicorn src.api.app:app --host 0.0.0.0 --port 8000 --reload
|
||||
# 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"
|
||||
ports:
|
||||
- "8000:8000"
|
||||
- "${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
|
||||
volumes:
|
||||
- ./src:/app/src
|
||||
- ./alembic:/app/alembic
|
||||
- ./alembic.ini:/app/alembic.ini
|
||||
|
||||
frontend:
|
||||
# Fix 4: 多阶段构建 —— 先 build 静态文件,再复制到 nginx
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile.frontend
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-3000}:80"
|
||||
volumes:
|
||||
- ./frontend/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
depends_on:
|
||||
- api
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
|
||||
+36
-28
@@ -13,16 +13,15 @@
|
||||
│ │
|
||||
│ 数据查询 预测编排 采集(手动/cron 触发) │
|
||||
│ ┌──────┐ ┌────────────┐ ┌───────────────────┐ │
|
||||
│ │matches│ │ orchestrator│ │ bzzoiro (赛果) │ │
|
||||
│ │matches│ │ orchestrator│ │ bzzoiro (唯一源) │ │
|
||||
│ │leagues│ │ ┌─ 5 专家并行(便宜模型) │ │
|
||||
│ └──┬───┘ │ │ h2h / form / stats / │ │
|
||||
│ │ │ │ home_away / injuries │ │
|
||||
│ │ │ │ home_away / standings │ │
|
||||
│ │ │ └─ aggregator 终裁(强模型) │ │
|
||||
│ │ └────────────┘ └───────────────────┘ │
|
||||
│ │ │ └ understat (xG) │
|
||||
│ ┌──┴──────────────┴──┐ └ injuries (伤停) │
|
||||
│ │ PostgreSQL (6 张表) │ httpx → 外部 API │
|
||||
│ └────────────────────┘ │
|
||||
│ │ └────────────┘ │ events / standings │ │
|
||||
│ ┌──┴──────────────┴──┐ │ /stats 三条管线 │ │
|
||||
│ │ PostgreSQL (13 张表)│ └───────────────────┘ │
|
||||
│ └────────────────────┘ httpx → 外部 API │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
@@ -31,7 +30,7 @@
|
||||
1. `POST /predict {match_id}` → orchestrator
|
||||
2. `load_match_header`: 查比赛 + 双方 + 联赛(一次 eager load)
|
||||
3. **5 个专家 agent 并行**(`asyncio.gather`),每个:
|
||||
- 各自的数据切片函数查库(近况/交锋/积分榜 SQL 聚合/伤停/xG)
|
||||
- 各自的数据切片函数查库(近况/交锋/积分榜聚合/射门控球/xG)
|
||||
- 切片无数据 → **跳过 LLM**,直接 `no_data` stub(省 token、防幻觉)
|
||||
- 有数据 → 专属 prompt(专家模型,便宜快)→ 结构化 JSON 报告(`home_edge` 方向性评分 + 证据)
|
||||
4. **终裁 agent**:5 份报告 + 比赛信息 → 权衡采信度(`agent_weights`)→ 最终预测 JSON
|
||||
@@ -44,7 +43,7 @@
|
||||
|---|---|
|
||||
| **多专家并行而非单次大 prompt** | 每维度独立迭代 prompt;报告可归因(哪个维度分析错了);总延迟 ≈ 2 次串行调用 |
|
||||
| **专家/终裁模型分档** | 专家用便宜模型快速分析,终裁用强模型汇总决策,成本与质量平衡(`LLM_SPECIALIST_MODEL` / `LLM_AGGREGATOR_MODEL`) |
|
||||
| **no_data 门控** | 无数据维度(如伤停未接入)不调 LLM,终裁知道维度缺失,不编造 |
|
||||
| **no_data 门控** | 无数据维度(如积分榜未采集)不调 LLM,终裁知道维度缺失,不编造 |
|
||||
| **fail-open** | 单个专家失败只标记 `status=error`,其余照常;研究场景可用性优先 |
|
||||
| **`match_date_date` 天级去重** | 不同源时间精度不同,秒级匹配会产生重复行;天级 + 数据库唯一约束 |
|
||||
| **积分榜 SQL 聚合 + season 过滤** | `UNION ALL` 主客双视角 + `GROUP BY` 在库内算,只算当前赛季(修复过跨赛季 bug) |
|
||||
@@ -57,33 +56,38 @@
|
||||
Profeto/
|
||||
├── src/
|
||||
│ ├── api/
|
||||
│ │ ├── app.py # FastAPI 工厂(lifespan 仅验证 DB 连接,不建表)
|
||||
│ │ ├── deps.py # 依赖:管理接口鉴权(X-API-Key)
|
||||
│ │ ├── app.py # FastAPI 工厂(lifespan:迁移校验/定时任务/生产限流提醒)
|
||||
│ │ ├── deps.py # 依赖:管理接口鉴权(Cookie/X-API-Key)+ 限流
|
||||
│ │ ├── schemas.py # Pydantic v2 请求/响应
|
||||
│ │ └── routes/
|
||||
│ │ ├── matches.py # 联赛/比赛查询(游标分页)
|
||||
│ │ ├── predict.py # 预测 + 预测历史
|
||||
│ │ ├── ingest.py # 采集触发(自管 session,需鉴权)
|
||||
│ │ ├── eval.py # 赛后回填 + 准确率汇总
|
||||
│ │ └── backtest.py # 历史回测(需鉴权)
|
||||
│ │ ├── matches.py # 联赛/比赛/上下文/积分榜(公开只读)
|
||||
│ │ ├── predict.py # 预测(限流)+ 预测历史(需鉴权)
|
||||
│ │ ├── ingest.py # 采集触发(需鉴权)
|
||||
│ │ ├── eval.py # 赛后回填 + 准确率汇总(需鉴权)
|
||||
│ │ ├── backtest.py # 历史回测(需鉴权)
|
||||
│ │ ├── auth.py # 登录/登出/改密
|
||||
│ │ ├── admin_settings.py # /admin/** 配置/日志/数据质量(router 级鉴权)
|
||||
│ │ └── schedules.py # 定时任务 + 死信重试(router 级鉴权)
|
||||
│ ├── db/
|
||||
│ │ ├── base.py # async engine + get_db/get_db_read
|
||||
│ │ ├── models.py # 6 张表 ORM
|
||||
│ │ ├── models.py # 13 张表 ORM
|
||||
│ │ ├── repositories.py # 仓储层
|
||||
│ │ └── unit_of_work.py # 事务边界
|
||||
│ ├── data/
|
||||
│ │ ├── bzzoiro.py # 赛果采集 + 幂等入库
|
||||
│ │ ├── understat.py # xG 回填
|
||||
│ │ ├── injuries.py # 伤停采集(带文件缓存)
|
||||
│ │ ├── bzzoiro.py # 唯一数据源:events/standings/stats 三管线 + Bronze 层
|
||||
│ │ ├── normalize.py # NormalizedMatch 清洗契约
|
||||
│ │ ├── team_names.py # 队名归一映射
|
||||
│ │ ├── team_names_zh.py # 队名中文名映射
|
||||
│ │ ├── key_ring.py # 多 key 轮换(429 冷却,进程内)
|
||||
│ │ ├── sources.py # 数据源注册表
|
||||
│ │ └── config.py # 联赛代码映射
|
||||
│ ├── llm/
|
||||
│ │ ├── provider.py # OpenAI-compatible 抽象(共享连接池/JSON 兜底解析)
|
||||
│ │ ├── context_builder.py # 数据切片(h2h/form/stats/home_away/injuries)+ 单 agent 拼接
|
||||
│ │ ├── context_builder.py # 数据切片(h2h/form/stats/home_away/standings)+ 单 agent 拼接
|
||||
│ │ ├── predict.py # 预测入口(mode 分派 + 缓存)
|
||||
│ │ ├── baseline.py # 基线预测(均值模型,mode=baseline)
|
||||
│ │ ├── eval.py # 准确率统计
|
||||
│ │ ├── utils.py # LLM 工具函数
|
||||
│ │ ├── validation.py # LLM 输出严格校验(Pydantic)
|
||||
│ │ ├── backtest.py # 回测执行
|
||||
│ │ ├── agents/
|
||||
@@ -91,16 +95,20 @@ Profeto/
|
||||
│ │ │ └── orchestrator.py # 并行专家 → 终裁 → 存库
|
||||
│ │ └── prompts/
|
||||
│ │ ├── match_prediction_v1/v2.md # 单 agent 模板
|
||||
│ │ └── agents/{h2h,form,stats,home_away,injuries,aggregator}_v1.md
|
||||
│ │ └── agents/{form,stats,home_away,standings,h2h,aggregator}_v1.md
|
||||
│ └── core/
|
||||
│ ├── config.py # pydantic-settings
|
||||
│ ├── crypto.py # 加密/哈希
|
||||
│ ├── http_client.py # 共享 httpx 客户端
|
||||
│ └── retry.py # 重试工具
|
||||
├── alembic/versions/ # 0001~0006(0001 建表 → 0006 漂移清理)
|
||||
├── frontend/src/pages/Matches.tsx # 单页(预测面板 + 专家报告折叠区 + 游标分页)
|
||||
├── tests/ # 核心 + agent 测试
|
||||
│ ├── log_buffer.py # 内存日志缓冲(admin 日志页)
|
||||
│ ├── runtime_config.py # DB 配置覆盖(app_settings)
|
||||
│ ├── scheduler.py # 进程内 cron 调度器
|
||||
│ └── security_check.py # 启动安全校验
|
||||
├── alembic/versions/ # 0001~0018(建表 → Bronze 层 → 单一数据源 → 基线模式等)
|
||||
├── frontend/src/ # pages/(公开站) + admin/(管理后台) + lib/http.ts(唯一 HTTP 实现)
|
||||
├── tests/ # 核心 + agent 测试(250+ 项,自包含)
|
||||
├── docker-compose.yml # api + postgres 两容器
|
||||
└── docs/ # 本文档
|
||||
└── docs/ # 本文档
|
||||
```
|
||||
|
||||
## 技术栈
|
||||
@@ -113,5 +121,5 @@ Profeto/
|
||||
| HTTP | httpx(共享连接池)/ urllib(bzzoiro 同步限速) |
|
||||
| LLM | OpenAI-compatible 接口(openai/deepseek/ollama 等任一) |
|
||||
| 前端 | Vite + React 18 + TypeScript + Tailwind |
|
||||
| 测试 | pytest + pytest-asyncio(33 项,自包含) |
|
||||
| 测试 | pytest + pytest-asyncio(250+ 项,自包含) |
|
||||
| 部署 | Docker Compose(api + postgres) |
|
||||
|
||||
+13
-4
@@ -66,6 +66,9 @@ curl -X POST http://localhost:8000/api/v1/ingest/bzzoiro \
|
||||
-d '{"leagues":["E0"],"date_from":"2026-08-01","date_to":"2026-09-08"}'
|
||||
```
|
||||
|
||||
> 注:采集/评估端点需管理员凭据。本地开发环境(未配置鉴权、非 production)默认放行;
|
||||
> 生产环境需先 `POST /auth/login` 取 Cookie,或带 `X-API-Key` 头。
|
||||
|
||||
数据量大时**直接拉整赛季**(约 380 场,含近几个赛季更好,近况/交锋/积分榜都需要历史):
|
||||
|
||||
```bash
|
||||
@@ -74,14 +77,20 @@ curl -X POST http://localhost:8000/api/v1/ingest/bzzoiro \
|
||||
-d '{"leagues":["E0"],"date_from":"2025-08-01","date_to":"2026-09-08"}'
|
||||
```
|
||||
|
||||
### 回填 xG(可选,让攻防数据 agent 有数据)
|
||||
### 回填积分榜与统计(让攻防/排名专家有数据)
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/v1/ingest/understat \
|
||||
curl -X POST http://localhost:8000/api/v1/ingest/bzzoiro \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"league":"E0","season":2025}'
|
||||
-d '{"task":"standings"}'
|
||||
|
||||
curl -X POST http://localhost:8000/api/v1/ingest/bzzoiro \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"task":"stats","leagues":["E0"],"limit":300}'
|
||||
```
|
||||
|
||||
`task=stats` 只补空字段(xG/射门/控球等),不创建比赛。
|
||||
|
||||
### 查比赛
|
||||
|
||||
浏览器打开 http://localhost:5173 ,选"英超 / 未开赛";
|
||||
@@ -126,4 +135,4 @@ curl http://localhost:8000/api/v1/eval/summary
|
||||
| predict 返回 502 | 看 uvicorn 日志的 LLM error;确认 `LLM_BASE_URL`/`LLM_API_KEY`;`response_format` 不兼容的网关会报错(改用支持 json mode 的模型) |
|
||||
| 采集 0 场 | bzzoiro Key 失效或联赛代码写错;先 `GET /api/v1/leagues` 看库里有没有联赛 |
|
||||
| 专家报告全是 no_data | 历史数据不够 —— 近况需要每队近 5 场、积分榜需要本赛季已完赛比赛,多拉几周数据 |
|
||||
| xg agent 报无 xG 数据 | 先跑 understat 回填;注意 understat 只有五大联赛 |
|
||||
| stats 专家报无 xG/统计 | 先跑 `task=stats` 回填(bzzoiro 统计管线,只补空字段) |
|
||||
|
||||
+91
-27
@@ -4,18 +4,30 @@ Base URL: `http://localhost:8000` · 交互式文档: `/docs`(Swagger)与 `/redo
|
||||
|
||||
所有数据端点返回 JSON。错误统一为 `{"detail": "<message>"}` + 对应 HTTP 状态码。
|
||||
|
||||
## 鉴权模型
|
||||
|
||||
| 级别 | 端点 | 说明 |
|
||||
|---|---|---|
|
||||
| **公开只读** | `GET /leagues`、`GET /matches`、`GET /matches/{id}`、`GET /matches/{id}/context`、`GET /standings`、`GET /health*` | 无需任何凭据;公开站直接调用 |
|
||||
| **公开 + 限流** | `POST /predict` | 内存滑动窗口限流(10 次/分钟/IP) |
|
||||
| **需管理员** | `GET /predictions*`、`POST /ingest/bzzoiro`、`/eval/*`、`POST /backtest`、`/admin/**` | Cookie 会话(`POST /auth/login` 颁发)或 `X-API-Key` 头 |
|
||||
|
||||
管理端点在生产环境未配置鉴权时 fail-closed(503),不会静默放行。
|
||||
|
||||
---
|
||||
|
||||
## 数据查询
|
||||
## 数据查询(公开只读)
|
||||
|
||||
### `GET /api/v1/leagues`
|
||||
|
||||
列出已入库联赛。
|
||||
列出已入库联赛(P1-3: 公开站联赛筛选动态加载来源)。
|
||||
|
||||
```json
|
||||
[{"id": 1, "code": "E0", "name": "Premier League", "country": "England"}]
|
||||
```
|
||||
|
||||
仅返回 `id/code/name/country` 四个展示字段,不含任何配置或密钥信息。
|
||||
|
||||
### `GET /api/v1/matches`
|
||||
|
||||
比赛列表,游标分页。
|
||||
@@ -47,7 +59,44 @@ Base URL: `http://localhost:8000` · 交互式文档: `/docs`(Swagger)与 `/redo
|
||||
|
||||
### `GET /api/v1/matches/{id}`
|
||||
|
||||
单场比赛详情,字段同上。
|
||||
单场比赛详情,字段同上,另含 `stats`(统计)与 `recent_predictions`(最近 5 条预测摘要)。
|
||||
|
||||
### `GET /api/v1/matches/{id}/context`
|
||||
|
||||
比赛上下文(公开只读,P1-2: 公开站详情页「近况/交锋」数据来源;不触发 LLM):
|
||||
|
||||
- `home_recent`: 主队最近 5 场已完赛
|
||||
- `away_recent`: 客队最近 5 场已完赛
|
||||
- `h2h`: 双方最近 5 次交手
|
||||
|
||||
```json
|
||||
{
|
||||
"home_recent": [
|
||||
{"match_date": "2026-09-12T14:00:00+00:00", "home_team": "阿森纳",
|
||||
"away_team": "切尔西", "home_goals": 2, "away_goals": 1}
|
||||
],
|
||||
"away_recent": [],
|
||||
"h2h": []
|
||||
}
|
||||
```
|
||||
|
||||
数据不足时对应列表为空(前端展示空态)。比赛不存在返回 404。
|
||||
|
||||
### `GET /api/v1/standings`
|
||||
|
||||
联赛积分榜(公开只读)。参数:`league`(联赛代码,空 = 全部)、`season`(空 = 各联赛最新赛季)。
|
||||
|
||||
```json
|
||||
{"leagues": [
|
||||
{"league_code": "E0", "league_name": "Premier League", "season": "2026-2027",
|
||||
"retrieved_at": "2026-09-20T08:00:00+00:00",
|
||||
"rows": [{"position": 1, "team": "阿森纳", "team_en": "Arsenal",
|
||||
"played": 5, "won": 4, "drawn": 1, "lost": 0,
|
||||
"goals_for": 11, "goals_against": 3, "goal_diff": 8,
|
||||
"points": 13, "xg_for": 9.8, "xg_against": 3.9,
|
||||
"form": "WWWDW", "zone": "UEFA Champions League"}]}
|
||||
]}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -96,13 +145,13 @@ Base URL: `http://localhost:8000` · 交互式文档: `/docs`(Swagger)与 `/redo
|
||||
"exp_home_goals": null, "exp_away_goals": null, "probable_score": null,
|
||||
"model": "gpt-4o-mini", "latency_ms": 2100,
|
||||
"prompt_tokens": 380, "completion_tokens": 120},
|
||||
{"agent": "injuries", "status": "no_data", "data_sufficiency": "none",
|
||||
{"agent": "standings", "status": "no_data", "data_sufficiency": "none",
|
||||
"analysis": "该维度无数据,跳过分析。", "home_edge": null, "subjective_confidence": null,
|
||||
"key_evidence": [], "exp_home_goals": null, "exp_away_goals": null,
|
||||
"probable_score": null, "model": "", "latency_ms": null,
|
||||
"prompt_tokens": null, "completion_tokens": null}
|
||||
],
|
||||
"agent_weights": {"form": 0.9, "stats": 0.8, "home_away": 0.7, "injuries": 0.0, "h2h": 0.8},
|
||||
"agent_weights": {"form": 0.9, "stats": 0.8, "home_away": 0.7, "standings": 0.8, "h2h": 0.8},
|
||||
"context": "[5 份报告的 JSON 串]",
|
||||
"latency_ms": 9800
|
||||
}
|
||||
@@ -112,11 +161,11 @@ Base URL: `http://localhost:8000` · 交互式文档: `/docs`(Swagger)与 `/redo
|
||||
|
||||
错误:404 比赛不存在;502 LLM 调用失败(终裁失败时整体失败,专家失败不会)。
|
||||
|
||||
### `GET /api/v1/predictions?match_id=&limit=`
|
||||
### `GET /api/v1/predictions?match_id=&limit=`(需管理员)
|
||||
|
||||
预测历史(倒序),含 `settled` 与实际比分回填状态。
|
||||
|
||||
### `GET /api/v1/predictions/{id}`
|
||||
### `GET /api/v1/predictions/{id}`(需管理员)
|
||||
|
||||
单条预测详情(含完整 `agent_outputs`)。
|
||||
|
||||
@@ -124,40 +173,52 @@ Base URL: `http://localhost:8000` · 交互式文档: `/docs`(Swagger)与 `/redo
|
||||
|
||||
## 数据采集
|
||||
|
||||
### `POST /api/v1/ingest/bzzoiro`
|
||||
### `POST /api/v1/ingest/bzzoiro`(需管理员)
|
||||
|
||||
从 bzzoiro 采集赛果/赛程并入库(幂等,重复跑安全)。
|
||||
从 bzzoiro(唯一数据源)采集数据并入库(幂等,重复跑安全)。任务在后台异步执行,请求立即返回。
|
||||
|
||||
```json
|
||||
{"leagues": ["E0", "SP1"], "date_from": "2025-08-01", "date_to": "2026-09-08", "status": "finished"}
|
||||
{"task": "all", "leagues": ["E0", "SP1"], "date_from": "2025-08-01", "date_to": "2026-09-08", "status": "finished"}
|
||||
```
|
||||
|
||||
- `status` 还可传 `scheduled` 拉未来赛程
|
||||
- 响应含每联赛 `inserted`/`updated`/`errors` 统计
|
||||
| 字段 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `task` | `events` | 采集任务:`events`(比赛)/ `standings`(积分榜)/ `stats`(统计回填,含 xG)/ `all` |
|
||||
| `leagues` | 全部已知联赛 | 联赛代码列表,如 `["E0", "SP1"]` |
|
||||
| `date_from` / `date_to` | 空 | 日期范围(`YYYY-MM-DD`) |
|
||||
| `status` | 空(两者都采) | `finished`(已完赛)/ `scheduled`(未来赛程) |
|
||||
| `season` | 当前赛季 | standings 赛季,如 `"2026-2027"` |
|
||||
| `limit` | 100 | stats 回填单次最大比赛数(1–500) |
|
||||
|
||||
### `POST /api/v1/ingest/understat`
|
||||
- 响应:`{"ok": true, "job_id": "<uuid>", "message": "……"}`,`job_id` 用于查询任务状态
|
||||
- `task=stats` 只补空字段、不创建比赛(xG/射门/控球等统计回填)
|
||||
|
||||
回填 xG(只补空字段,不创建比赛):
|
||||
### `GET /api/v1/admin/ingest/jobs/{job_id}`(需管理员)
|
||||
|
||||
查询一次采集任务的状态(`ingest_jobs` 表,任务级可观测性):
|
||||
|
||||
```json
|
||||
{"league": "E0", "season": 2025}
|
||||
{"id": "…", "task": "standings", "params": {"leagues": ["E0"]},
|
||||
"status": "success", "result": {"total_upserted": 20, "errors": []},
|
||||
"error": null, "created_at": "…", "started_at": "…", "finished_at": "…"}
|
||||
```
|
||||
|
||||
`season=2025` 表示 2025-2026 赛季。仅支持五大联赛。
|
||||
- `status` 取值:`pending`(已创建未开始)/ `running` / `success` / `failed`
|
||||
- 管理端采集页提交后凭 `job_id` 轮询本端点直至终态
|
||||
- 与 `ingest_failures` 死信独立:死信记录单条管线抓取失败(行级),job 记录整次任务结果
|
||||
|
||||
### `POST /api/v1/ingest/injuries`
|
||||
### `GET /api/v1/admin/ingest/jobs?limit=20&status=`(需管理员)
|
||||
|
||||
采集伤停(需 `API_FOOTBALL_KEY`,当前只返回计数,尚未接入 context):
|
||||
列出最近采集任务(最新在前),可按 `status` 过滤,`limit` 1–100。
|
||||
|
||||
```json
|
||||
{"date": "2026-09-10"}
|
||||
```
|
||||
> 历史版本曾有独立的 understat(xG)与 injuries(伤停)采集端点,
|
||||
> 已随数据源收敛为 bzzoiro 唯一来源而移除。
|
||||
|
||||
---
|
||||
|
||||
## 评估
|
||||
|
||||
### `POST /api/v1/eval/settle`
|
||||
### `POST /api/v1/eval/settle`(需管理员)
|
||||
|
||||
赛后回填实际比分:
|
||||
|
||||
@@ -165,7 +226,7 @@ Base URL: `http://localhost:8000` · 交互式文档: `/docs`(Swagger)与 `/redo
|
||||
{"prediction_id": 7, "home_goals": 2, "away_goals": 1}
|
||||
```
|
||||
|
||||
### `GET /api/v1/eval/summary`
|
||||
### `GET /api/v1/eval/summary`(需管理员)
|
||||
|
||||
按 `provider × model` 聚合已结算预测:
|
||||
|
||||
@@ -183,7 +244,10 @@ Base URL: `http://localhost:8000` · 交互式文档: `/docs`(Swagger)与 `/redo
|
||||
|
||||
## 基础
|
||||
|
||||
| 端点 | 说明 |
|
||||
|---|---|
|
||||
| `GET /health` | 存活检查 |
|
||||
| `GET /docs` | Swagger UI |
|
||||
| 端点 | 权限 | 说明 |
|
||||
|---|---|---|
|
||||
| `GET /health` | 公开 | 存活检查 |
|
||||
| `GET /docs`、`GET /redoc` | 公开 | Swagger UI / ReDoc |
|
||||
| `POST /auth/login`、`POST /auth/logout`、`GET /auth/me` | 公开 | 管理员 Cookie 会话登录/登出/当前用户 |
|
||||
| `POST /auth/change-password` | 需管理员 | 修改管理员密码 |
|
||||
| `POST /backtest` | 需管理员 | 历史回测(对已完赛比赛批量预测并评估) |
|
||||
|
||||
+20
-7
@@ -10,7 +10,7 @@ Profeto 的核心预测路径是 **5 个领域专家 Agent 并行分析 + 1 个
|
||||
| `form` 近期状态 | 分析比分与关键事件,判断近期走势 | 两队近 N 场赛果(含 xG) | `home_edge` + 走势判断 |
|
||||
| `stats` 攻防数据 | 评估进球、射门与控球,量化攻防强度 | 近 N 场进球/射门/控球/xG 统计 | `home_edge` + 攻防强度 |
|
||||
| `home_away` 主客因素 | 对比主场与客场表现,评估地理优势影响 | 主队主场战绩 + 客队客场战绩 | `home_edge` + 地理优势 |
|
||||
| `injuries` 阵容完整性 | 汇总伤停与停赛名单,评估战力缺失程度 | 伤停数据(当前无源 → no_data 门控) | `home_edge` 或 `no_data` |
|
||||
| `standings` 联赛排名 | 结合积分榜排名、积分与分区(欧冠/欧联/降级),评估双方竞争位置 | 两队当前赛季积分榜行(排名/积分/分区/近期战绩) | `home_edge` 或 `no_data` |
|
||||
| `h2h` 历史交锋 | 分析过去数年以及近期的交手数据,提取交手规律 | 近 N 次交锋(含主客方向 + 总计统计) | `home_edge` + 交手规律 |
|
||||
| `aggregator` 终裁 | 权衡 5 份报告 → 最终结论 | 5 份结构化报告 + 比赛头信息 | 最终预测 + 各报告采信度 |
|
||||
|
||||
@@ -25,7 +25,7 @@ POST /predict {match_id, mode: "multi"}
|
||||
│ ├─ form agent ─┐
|
||||
│ ├─ stats agent │ 每个 agent 拿到专属数据切片
|
||||
│ ├─ home_away agent │ → no_data 门控 → 调 LLM → 输出 JSON 报告
|
||||
│ ├─ injuries agent │ (无数据 → 跳过 LLM,返回 stub)
|
||||
│ ├─ standings agent │ (无数据 → 跳过 LLM,返回 stub)
|
||||
│ └─ h2h agent ─┘
|
||||
│
|
||||
├─ aggregator agent(5 份报告 + 比赛头 → 最终 JSON)
|
||||
@@ -39,12 +39,12 @@ POST /predict {match_id, mode: "multi"}
|
||||
5 个专家通过 `asyncio.gather` 并发,总延迟 ≈ `max(专家延迟) + 终裁延迟` ≈ 2 次串行 LLM 调用。
|
||||
|
||||
### 2. no_data 门控(省 token、防幻觉)
|
||||
数据切片为空时(如伤停数据源未接入),**跳过 LLM 调用**,直接返回:
|
||||
数据切片为空时(如该场比赛的积分榜尚未采集),**跳过 LLM 调用**,直接返回:
|
||||
```json
|
||||
{"agent": "injuries", "status": "no_data", "data_sufficiency": "none",
|
||||
{"agent": "standings", "status": "no_data", "data_sufficiency": "none",
|
||||
"analysis": "该维度无数据,跳过分析。"}
|
||||
```
|
||||
终裁 Agent 会看到这个 `no_data` 状态,不会编造伤停分析。
|
||||
终裁 Agent 会看到这个 `no_data` 状态,不会编造积分榜分析。
|
||||
|
||||
### 3. fail-open(单专家失败不阻断)
|
||||
单个专家 LLM 调用失败 → 其报告标记 `status: error`,其余 4 份 + 终裁照常执行。
|
||||
@@ -89,7 +89,7 @@ POST /predict {match_id, mode: "multi"}
|
||||
"1x2": "1",
|
||||
"subjective_confidence": 0.68,
|
||||
"reasoning": "综合 stats 报告的攻防强度与 form 报告的三连胜势头……",
|
||||
"agent_weights": {"form": 0.9, "stats": 0.8, "home_away": 0.7, "injuries": 0.0, "h2h": 0.8}
|
||||
"agent_weights": {"form": 0.9, "stats": 0.8, "home_away": 0.7, "standings": 0.8, "h2h": 0.8}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -120,7 +120,7 @@ agents/
|
||||
├── form_v1.md # 近期状态专家
|
||||
├── stats_v1.md # 攻防数据专家
|
||||
├── home_away_v1.md # 主客因素专家
|
||||
├── injuries_v1.md # 阵容完整性专家
|
||||
├── standings_v1.md # 联赛排名专家
|
||||
├── h2h_v1.md # 历史交锋专家
|
||||
└── aggregator_v1.md # 终裁
|
||||
```
|
||||
@@ -129,6 +129,19 @@ agents/
|
||||
迭代 prompt 时:复制 `h2h_v1.md` → `h2h_v2.md`,改内容,传 `prompt_version: "v2"`。
|
||||
`predictions.prompt_version` 存的是 `multi_v2`,与 single 模式的 `v1`/`v2` 天然分组,可在 eval summary 中 A/B 对比。
|
||||
|
||||
### 版本纪律(强制)
|
||||
|
||||
> **改 prompt 内容必须 bump 版本号(v2→v3),禁止默默修改 `*_v1.md` 内容却不改版本。**
|
||||
|
||||
原因:
|
||||
1. **可复现性**:`predictions.prompt_version` 决定哪份 prompt 产生了历史预测;篡改 v1 会让历史预测的 prompt 来源失真,eval 对比失效。
|
||||
2. **A/B 可信度**:`get_eval_summary` 按 `(provider, model, prompt_version)` 分组。若 v1 内容在不同时间指向不同 prompt,则 v1 桶内数据不可比。
|
||||
3. **缓存一致性**:模板内容 hash 写入缓存键(`_prompt_template_hash`),版本不变则 hash 不变,命中旧缓存。bump 版本自动让旧缓存失效。
|
||||
|
||||
**流程**:改 prompt → 新建 `*_v{N+1}.md` → 新请求传 `prompt_version=v{N+1}` → 旧版本文件保持不变(供历史复现)。
|
||||
|
||||
代码保证:写入 DB 的 `prompt_version` 与 `_load_prompt_template(version)` / `load_agent_prompt(name, version)` 加载的文件**严格一致**,不会漂移。
|
||||
|
||||
## 如何新增一个专家 Agent
|
||||
|
||||
三步:
|
||||
|
||||
+37
-13
@@ -4,9 +4,10 @@
|
||||
|
||||
| 数据源 | 用途 | 必需 Key | 说明 |
|
||||
|---|---|---|---|
|
||||
| bzzoiro | 赛果/赛程(主源) | `BZZOIRO_KEY` | 五大联赛历史 + 实时 |
|
||||
| understat | xG 回填 | 无(公开) | 仅五大联赛,补 `match_stats.xg` |
|
||||
| api-football | 伤停 | `API_FOOTBALL_KEY` | 当前只采集计数,未接入 context |
|
||||
| bzzoiro(唯一) | 赛果/赛程/积分榜/统计(xG、射门、控球等) | `BZZOIRO_KEY` | 五大联赛 + 欧战,历史 + 实时 |
|
||||
|
||||
> 历史版本曾有 understat(xG 回填)与 api-football(伤停)两个辅助源,
|
||||
> 现已移除:数据源收敛为 bzzoiro 唯一来源,统计与积分榜均由 bzzoiro 管线采集。
|
||||
|
||||
### bzzoiro
|
||||
|
||||
@@ -17,11 +18,16 @@
|
||||
BZZOIRO_LEAGUE_IDS = {"E0": 1, "SP1": 3, "D1": 5, "I1": 4, "F1": 6, "CL": 7, "EL": 8}
|
||||
```
|
||||
|
||||
### understat
|
||||
|
||||
- 端点:`/getLeagueData/{league}/{season}`,返回 JS 包裹的 JSON(需正则提取)
|
||||
- 只回填 xG(`match_stats.home_xg`/`away_xg`),**不创建新比赛**
|
||||
- 通过"天级日期 + 队名归一"匹配已有比赛
|
||||
> ⚠️ **统计字段映射待验证**:当前字段名基于常见足球 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`
|
||||
|
||||
## 数据清洗契约
|
||||
|
||||
@@ -59,7 +65,7 @@
|
||||
|
||||
## 数据库 Schema
|
||||
|
||||
6 张表:
|
||||
12 张业务/配置表 + 1 张任务状态表(`ingest_jobs`):核心业务表 5 张见下方 DDL,其余见后文表格。
|
||||
|
||||
```sql
|
||||
-- 联赛
|
||||
@@ -133,12 +139,26 @@ CREATE TABLE predictions (
|
||||
reasoning TEXT,
|
||||
raw_response JSONB, -- LLM 完整原始响应
|
||||
agent_outputs JSONB, -- multi 模式: 5 份专家报告
|
||||
agent_weights JSONB, -- multi 模式: 终裁给出的各专家权重
|
||||
created_at TIMESTAMPTZ,
|
||||
actual_home_goals INT, actual_away_goals INT, -- 赛后回填
|
||||
settled BOOLEAN DEFAULT FALSE
|
||||
);
|
||||
```
|
||||
|
||||
其余 8 张表(DDL 略,详见 `src/db/models.py` 与 alembic 迁移):
|
||||
|
||||
| 表 | 状态 | 用途 |
|
||||
|---|---|---|
|
||||
| `standings` | 已启用 | 联赛积分榜快照,按 `(league_id, season, team_id)` upsert,同联赛同赛季只保留最新快照;含排名/战绩/进失球/积分/分区(zone) |
|
||||
| `app_settings` | 已启用 | 后台运行时设置(如数据源 API Key),读取时优先于 `.env` 默认值 |
|
||||
| `schedules` | 已启用 | 定时采集任务配置(task/cron/leagues/enabled),供内置调度器执行 |
|
||||
| `ingest_jobs` | 已启用 | 采集任务状态:POST ingest 创建 pending,后台流转 running→success/failed;result 存统计摘要,供 admin 轮询(任务级,与死信互补) |
|
||||
| `raw_events` | 预留未启用 | Bronze 层原始事件存档;规划中用于重放与审计 |
|
||||
| `ingest_failures` | 已启用 | 采集失败死信:bzzoiro 三条管线(events/standings/stats)抓取失败时写入,admin 后台可查看与重试 |
|
||||
| `data_quality_checks` | 预留未启用 | 数据质量检查结果;规划中定时检查比赛/统计/积分榜完整性 |
|
||||
| `data_lineage` | 预留未启用 | ETL 血缘追踪;规划中记录源记录到目标表的映射 |
|
||||
|
||||
### 关键设计点
|
||||
|
||||
1. **`match_date_date`(天级日期)**: 用于天级去重。bzzoiro 返回的时间带时分秒,精确匹配不可靠,故拆出 `DATE` 列做唯一键。
|
||||
@@ -159,7 +179,9 @@ CREATE TABLE predictions (
|
||||
- 状态:只允许单向升级(`scheduled` → `finished`),防止完赛行被覆盖成赛程
|
||||
- stats:只补空(`home_xg` 已有值时不覆盖)
|
||||
|
||||
`ingest_understat` 只回填 xG(也只补空),不创建比赛。
|
||||
`task=stats` 只回填统计(xG/射门/控球等,也只补空),不创建比赛。
|
||||
|
||||
`task=standings` 按 `(league_id, season, team_id)` upsert 积分榜快照,同一联赛同一赛季只保留最新一份。
|
||||
|
||||
## 采集建议
|
||||
|
||||
@@ -172,9 +194,11 @@ curl -X POST /api/v1/ingest/bzzoiro \
|
||||
curl -X POST /api/v1/ingest/bzzoiro \
|
||||
-d '{"leagues":["E0"],"date_from":"2026-09-01","date_to":"2026-09-08"}'
|
||||
|
||||
# 3. xG 回填(可选,提升 xg agent 质量)
|
||||
curl -X POST /api/v1/ingest/understat -d '{"league":"E0","season":2025}'
|
||||
curl -X POST /api/v1/ingest/understat -d '{"league":"E0","season":2026}'
|
||||
# 3. 积分榜 + 统计回填(xG/射门/控球,提升 stats/standings 专家质量)
|
||||
curl -X POST /api/v1/ingest/bzzoiro -d '{"task":"standings"}'
|
||||
curl -X POST /api/v1/ingest/bzzoiro -d '{"task":"stats","leagues":["E0"],"limit":300}'
|
||||
|
||||
# 注:采集端点需管理员凭据(Cookie 会话或 X-API-Key 头),下同
|
||||
```
|
||||
|
||||
建议用外部 cron(如系统 crontab)定时触发,不引入 worker/redis。
|
||||
|
||||
+153
-12
@@ -13,22 +13,42 @@
|
||||
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` 仅 2 个服务:
|
||||
`docker-compose.yml` 包含 3 个服务:
|
||||
|
||||
| 服务 | 端口 | 说明 |
|
||||
|---|---|---|
|
||||
| `postgres` | 5432 | PostgreSQL 16 |
|
||||
| `api` | 8000 | FastAPI 应用 |
|
||||
| `postgres` | 5433 | PostgreSQL 16 |
|
||||
| `api` | 8000 | FastAPI 应用(启动时自动执行 `alembic upgrade head`) |
|
||||
| `frontend` | 3000 | React 前端(多阶段构建,nginx 服务静态文件) |
|
||||
|
||||
数据卷 `pgdata` 持久化数据库,重启不丢数据。
|
||||
|
||||
> **注意**: `api` 服务启动时会先执行 `alembic upgrade head` 迁移数据库,再启动 uvicorn。
|
||||
> 容器内数据库连接自动使用 `postgres` 服务名(通过 compose `environment` 覆盖 `.env` 中的 `DB_HOST`)。
|
||||
|
||||
## 生产上线检查清单
|
||||
|
||||
公网上线前逐项勾选。第 1–4 项由 `src/core/security_check.py` 在 `APP_ENV=production`
|
||||
启动时**强制校验**,不满足直接拒绝启动(开发环境仅告警);管理鉴权另有请求期 fail-closed(503)。
|
||||
|
||||
- [ ] **1. `APP_ENV=production`** — 安全校验、Cookie `Secure`、管理端点 fail-closed 均以它为总开关
|
||||
- [ ] **2. `SECRET_KEY` 强随机** — 用 `openssl rand -base64 32` 生成;禁止弱值/短值(弱值黑名单会拒绝启动,含把生成指令原样粘进去的情况)
|
||||
- [ ] **3. 管理鉴权至少其一** — `ADMIN_PASSWORD` 或 `ADMIN_API_KEY`(后台改过密码后以数据库哈希优先);两者皆空时管理接口 503
|
||||
- [ ] **4. 数据库强密码** — 禁止 `football:football` 等示例弱密码(启动校验会拒绝);compose 的 `POSTGRES_PASSWORD` 必填,缺失时容器拒绝启动
|
||||
- [ ] **5. HTTPS** — 由反代(Nginx/Caddy)终结 TLS;`APP_ENV=production` 下会话 Cookie 自动 `Secure`(且 HttpOnly + SameSite=Lax)
|
||||
- [ ] **6. 反代信任头** — `TRUST_PROXY_HEADERS=True`,且**仅可信反代可达 API**;反代需设置 `X-Forwarded-For`(`$proxy_add_x_forwarded_for`)与 `X-Real-IP`,否则限流/日志按反代 IP 计数
|
||||
- [ ] **7. 限流前置到网关** — 推荐 Nginx `limit_req`(配置见[安全与限流](#安全与限流));应用内限流与 KeyRing 为**单进程内存实现**,多 worker 各自独立计数会把实际配额放大 N 倍(启动时会打印一次性告警)
|
||||
- [ ] **8. uvicorn 单 worker** — compose/Dockerfile 默认单 worker,保持即可;需横向扩容时先在网关统一限流,再起多实例(每实例仍单 worker)
|
||||
- [ ] **9. 启动后健康检查** — `curl /health` 返回 200(存活);`curl /health/ready` 返回 200(就绪,校验数据库连通,不可达时 503)
|
||||
- [ ] **10. 数据库迁移** — compose/Dockerfile 启动命令已内置 `alembic upgrade head && uvicorn …`,升级镜像重启即自动迁移,无需手动执行
|
||||
|
||||
## 本地开发部署
|
||||
|
||||
```bash
|
||||
@@ -63,7 +83,13 @@ cd frontend && npm install && npm run dev
|
||||
|---|---|---|---|
|
||||
| `APP_ENV` | ❌ | `development` | `production` / `development` |
|
||||
| `LOG_LEVEL` | ❌ | `INFO` | 日志级别 |
|
||||
| `DATABASE_URL` | ✅ | — | PostgreSQL 连接 URL |
|
||||
| `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 内会被覆盖) |
|
||||
| `LLM_PROVIDER` | ❌ | `openai` | 提供商名(仅标记) |
|
||||
| `LLM_API_KEY` | ✅ | — | API Key |
|
||||
| `LLM_BASE_URL` | ❌ | `https://api.openai.com/v1` | 接口地址(Ollama/Deepseek 用) |
|
||||
@@ -71,10 +97,11 @@ cd frontend && npm install && npm run dev
|
||||
| `LLM_TIMEOUT` | ❌ | `60` | 单次调用超时(秒) |
|
||||
| `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 |
|
||||
| `BZZOIRO_KEY` | ✅ | — | bzzoiro 数据源 Key(唯一数据源) |
|
||||
| `CORS_ORIGINS` | ❌ | `http://localhost:5173,...` | 允许的跨域来源 |
|
||||
| `SECRET_KEY` | ❌ | — | 加密主密钥(生产环境必填) |
|
||||
| `ADMIN_PASSWORD` | ❌ | — | 管理后台密码(留空=不启用) |
|
||||
| `ADMIN_API_KEY` | ❌ | — | 机器/脚本调用的 API Key |
|
||||
|
||||
## LLM 提供商配置示例
|
||||
|
||||
@@ -127,9 +154,16 @@ alembic revision --autogenerate -m "描述"
|
||||
alembic revision -m "描述"
|
||||
```
|
||||
|
||||
**Docker Compose 自动迁移**: `api` 容器启动时会自动执行 `alembic upgrade head`,
|
||||
无需手动运行。本地开发时需手动执行迁移。
|
||||
|
||||
已有迁移:
|
||||
- `0001_initial`: 初始 5 张表,0003 增加 injuries,0004 增加约束,0005 增加时间语义
|
||||
- `0001_initial`: 初始 5 张表
|
||||
- `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 等
|
||||
|
||||
## 备份与恢复
|
||||
|
||||
@@ -143,6 +177,113 @@ cat backup.sql | docker exec -i profeto-postgres psql -U football football
|
||||
|
||||
## 监控
|
||||
|
||||
- `/health`: 存活检查
|
||||
- 日志:容器 stdout(`docker compose logs -f api`)
|
||||
- 评估汇总:`GET /api/v1/eval/summary`(准确率/RMSAE/校准度)
|
||||
### 健康检查
|
||||
|
||||
| 端点 | 含义 | HTTP 状态码 |
|
||||
|---|---|---|
|
||||
| `/health` | 存活检查(liveness) | 始终 200(进程在跑即活) |
|
||||
| `/health/ready` | 就绪检查(readiness) | DB 可达 200,不可达 **503** |
|
||||
|
||||
### 探针配置
|
||||
|
||||
#### Docker Compose
|
||||
|
||||
`docker-compose.yml` 已为 `api` 服务配置 readiness:
|
||||
|
||||
```yaml
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -sf http://localhost:8000/health/ready || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
```
|
||||
|
||||
**要点**:必须指向 `/health/ready` 而非 `/health`——后者始终 200,在数据库故障时仍会接收流量,导致请求全部失败。
|
||||
|
||||
#### Kubernetes
|
||||
|
||||
```yaml
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 15
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health/ready
|
||||
port: 8000
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
failureThreshold: 3
|
||||
```
|
||||
|
||||
**两探针必须区分**:
|
||||
- `livenessProbe` 用 `/health`:仅在进程死锁/崩溃时重启,避免误杀。
|
||||
- `readinessProbe` 用 `/health/ready`:DB 不可用时停止转发流量,恢复后自动切回。
|
||||
|
||||
#### 验证
|
||||
|
||||
```bash
|
||||
# 宿主机直接运行(经本地 8000 端口)
|
||||
python3 tests/test_health_ready.py
|
||||
```
|
||||
|
||||
### 评估
|
||||
|
||||
## 安全与限流
|
||||
|
||||
### 内存限流(按进程)
|
||||
|
||||
`/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 | 管理会话有效期(天) |
|
||||
|
||||
+58
-15
@@ -2,32 +2,70 @@
|
||||
|
||||
## 本地开发环境搭建
|
||||
|
||||
### 锁定依赖策略
|
||||
|
||||
项目使用 [pip-tools](https://github.com/jazzband/pip-tools) 锁定依赖版本,确保本地、CI、Docker 三端一致:
|
||||
|
||||
| 文件 | 用途 | 生成命令 |
|
||||
|---|---|---|
|
||||
| `requirements.txt` | 生产依赖锁定(含 SHA256 哈希) | `pip-compile pyproject.toml --generate-hashes` |
|
||||
| `requirements-dev.txt` | 开发+CI 依赖锁定(含哈希) | `pip-compile pyproject.toml --extra dev --generate-hashes` |
|
||||
|
||||
### 安装步骤
|
||||
|
||||
```bash
|
||||
# 1. 克隆并进入项目
|
||||
cd Profeto
|
||||
|
||||
# 2. 安装依赖(含 dev)
|
||||
pip install -e ".[dev]"
|
||||
# 2. 创建虚拟环境
|
||||
python3.11 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
|
||||
# 3. 启动 PostgreSQL
|
||||
# 3. 安装 pip-tools(用于同步锁定依赖)
|
||||
pip install pip-tools
|
||||
|
||||
# 4. 同步生产+开发依赖到当前环境(严格按 lock 文件版本,含哈希校验)
|
||||
pip-sync requirements-dev.txt
|
||||
|
||||
# 5. 启动 PostgreSQL(或在 .env 配置外部库)
|
||||
docker run -d --name profeto-pg \
|
||||
-e POSTGRES_USER=football -e POSTGRES_PASSWORD=football -e POSTGRES_DB=football \
|
||||
-p 5432:5432 postgres:16-alpine
|
||||
|
||||
# 4. 配置环境变量
|
||||
# 6. 配置环境变量
|
||||
cp .env.example .env
|
||||
# 编辑 .env 填 LLM_API_KEY / BZZOIRO_KEY
|
||||
|
||||
# 5. 建表
|
||||
# 7. 建表
|
||||
alembic upgrade head
|
||||
|
||||
# 6. 启动 API(热重载)
|
||||
# 8. 启动 API(热重载)
|
||||
uvicorn src.api.app:app --reload
|
||||
|
||||
# 7. 启动前端(另一个终端)
|
||||
# 9. 启动前端(另一个终端)
|
||||
cd frontend && npm install && npm run dev
|
||||
```
|
||||
|
||||
> **注意**:不要用 `pip install -e ".[dev]"` 直接安装——它按 pyproject 下界约束解析,版本可能与 lock 文件不一致。统一用 `pip-sync requirements-dev.txt` 保证三端一致。
|
||||
|
||||
### 变更依赖时
|
||||
|
||||
```bash
|
||||
# 1. 编辑 pyproject.toml(调整依赖或版本约束)
|
||||
|
||||
# 2. 重新生成 lock 文件(含哈希)
|
||||
pip-compile pyproject.toml --generate-hashes --output-file=requirements.txt --index-url=https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
pip-compile pyproject.toml --extra dev --generate-hashes --output-file=requirements-dev.txt --index-url=https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
|
||||
# 3. 同步到本地环境
|
||||
pip-sync requirements-dev.txt
|
||||
|
||||
# 4. 提交 lock 文件
|
||||
git add requirements.txt requirements-dev.txt pyproject.toml
|
||||
```
|
||||
|
||||
> **禁止无故大升级主版本依赖**:仅升级真正需要的包,并重新跑全量测试。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
@@ -45,16 +83,16 @@ Profeto/
|
||||
│ │ └── app.py # FastAPI 工厂
|
||||
│ ├── db/
|
||||
│ │ ├── base.py # SQLAlchemy async engine + session
|
||||
│ │ ├── models.py # 6 张表 ORM
|
||||
│ │ ├── models.py # 12 张表 ORM
|
||||
│ │ ├── repositories.py # 仓储层(查询封装)
|
||||
│ │ └── unit_of_work.py # 事务边界
|
||||
│ ├── data/
|
||||
│ │ ├── bzzoiro.py # bzzoiro 采集 + 入库
|
||||
│ │ ├── understat.py # understat xG 回填
|
||||
│ │ ├── injuries.py # 伤停采集
|
||||
│ │ ├── bzzoiro.py # bzzoiro 采集 + 入库(唯一数据源)
|
||||
│ │ ├── normalize.py # 数据清洗契约
|
||||
│ │ ├── team_names.py # 队名归一化映射
|
||||
│ │ ├── team_names_zh.py # 队名中文名映射
|
||||
│ │ ├── sources.py # 数据源注册表
|
||||
│ │ ├── key_ring.py # 数据源 Key 读取(DB 设置优先于 env)
|
||||
│ │ └── config.py # 联赛映射常量
|
||||
│ ├── llm/
|
||||
│ │ ├── provider.py # LLM 提供商抽象(OpenAI-compatible)
|
||||
@@ -72,13 +110,17 @@ Profeto/
|
||||
│ │ ├── form_v1.md
|
||||
│ │ ├── stats_v1.md
|
||||
│ │ ├── home_away_v1.md
|
||||
│ │ ├── injuries_v1.md
|
||||
│ │ ├── standings_v1.md
|
||||
│ │ ├── h2h_v1.md
|
||||
│ │ └── aggregator_v1.md
|
||||
│ └── core/
|
||||
│ ├── config.py # pydantic-settings 配置
|
||||
│ ├── http_client.py # 共享 httpx 客户端
|
||||
│ └── retry.py # 重试工具
|
||||
│ ├── crypto.py # 对称加密(Fernet)与密码哈希
|
||||
│ ├── log_buffer.py # 内存日志缓冲(admin「系统日志」页)
|
||||
│ ├── runtime_config.py # 运行时配置(数据库优先,回落 .env)
|
||||
│ ├── scheduler.py # 定时任务调度器(cron 触发采集)
|
||||
│ └── security_check.py # 生产启动安全校验(缺配置拒绝启动)
|
||||
├── frontend/ # React 单页前端
|
||||
├── alembic/ # 数据库迁移
|
||||
│ └── versions/
|
||||
@@ -87,7 +129,8 @@ Profeto/
|
||||
│ ├── 0003_injuries.py
|
||||
│ ├── 0004_snapshot_and_constraints.py
|
||||
│ ├── 0005_prediction_status_and_stats_provenance.py
|
||||
│ └── 0006_schema_model_drift_cleanup.py
|
||||
│ ├── 0006_schema_model_drift_cleanup.py
|
||||
│ └── …… (共 19 个迁移,最新 0019_ingest_jobs)
|
||||
├── tests/ # 测试
|
||||
│ ├── test_core.py # 核心逻辑测试
|
||||
│ └── test_agents.py # 多 agent 测试
|
||||
@@ -156,7 +199,7 @@ cp src/llm/prompts/agents/h2h_v1.md src/llm/prompts/agents/h2h_v2.md
|
||||
|
||||
### 3. 新增数据源
|
||||
|
||||
1. 在 `src/data/` 写采集模块(参考 `understat.py`)
|
||||
1. 在 `src/data/` 写采集模块(参考 `bzzoiro.py`)
|
||||
2. 在 `normalize.py` 加清洗函数
|
||||
3. 在 `context_builder.py` 加切片函数
|
||||
4. 在 `api/routes/ingest.py` 加端点
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
> ⚠️ **历史文档(已过时)**:伤停(injuries)数据源与 api-football 集成已移除,
|
||||
> 数据源收敛为 bzzoiro 唯一来源。本文仅作历史决策记录保留,
|
||||
> 现状请见 [05-data.md](05-data.md) 与 [01-architecture.md](01-architecture.md)。
|
||||
|
||||
---
|
||||
|
||||
"""检查 injuries 数据源 api-football 的响应结构。"""
|
||||
API_FOOTBALL_INJURY_RESPONSE_EXAMPLE = """
|
||||
{
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# 前端构建时不需要的文件
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
*.log
|
||||
@@ -0,0 +1,25 @@
|
||||
# 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;"]
|
||||
@@ -0,0 +1,42 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
# Serve static files
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# API proxy
|
||||
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;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Health check proxy
|
||||
location /health {
|
||||
proxy_pass http://api:8000/health;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# SPA routing - serve index.html for all routes
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
gzip_min_length 1000;
|
||||
}
|
||||
Generated
+43
-43
@@ -9,7 +9,8 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.30.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
@@ -766,9 +767,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -816,6 +814,15 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.4.tgz",
|
||||
"integrity": "sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
@@ -915,9 +922,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -932,9 +936,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -949,9 +950,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -966,9 +964,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -983,9 +978,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1000,9 +992,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1017,9 +1006,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1034,9 +1020,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1051,9 +1034,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1068,9 +1048,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1085,9 +1062,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1102,9 +1076,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1119,9 +1090,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2287,6 +2255,38 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "6.30.6",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.6.tgz",
|
||||
"integrity": "sha512-5HfK7k5im7LTOB0EqCQmfvy4C13G92Ssj1VTmouTK3AJvyjKTnFuCV0vcMAD/JS+JC4DvDIBRrlAeJIFjh5VWg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "6.30.6",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.6.tgz",
|
||||
"integrity": "sha512-0RHKZz7wwffvkU+2MFVT2NnjK44ssLEV+m0CAJaS2Ksmorrwj7WxH00jO0SOCW26/tINUnJHToXblDs33I38YQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.4",
|
||||
"react-router": "6.30.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8",
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.2.tgz",
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.30.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
|
||||
+117
-37
@@ -1,7 +1,23 @@
|
||||
/**
|
||||
* 主应用入口
|
||||
*
|
||||
* 页面结构:
|
||||
* - / → 先知主站(报纸风赛程 + 预测),报头含「评估」「管理」入口
|
||||
* - /admin/* → 管理后台(鉴权门禁,未登录引导登录)
|
||||
*
|
||||
* 导航策略:
|
||||
* - 首页报头放「评估」「管理」入口(极简,不另加导航条)
|
||||
* - 管理后台由 AdminLayout 侧边栏处理所有管理页导航
|
||||
* - 未登录访问管理 → AdminLayout 门禁 → 登录页(不静默失败)
|
||||
*/
|
||||
|
||||
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'
|
||||
|
||||
/** 报眉日期行:2026年9月15日 星期二 */
|
||||
/** 报眉日期行 */
|
||||
function dateLine(): string {
|
||||
return new Date().toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
@@ -11,45 +27,109 @@ 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 HomePage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-paper-50">
|
||||
{/* ── 报头:粗线 + 居中刊名 + 日期与分区链接 ── */}
|
||||
<header className="masthead-rule">
|
||||
<div className="mx-auto max-w-5xl px-5 sm:px-8">
|
||||
<div className="border-b border-ink-900 py-5 text-center sm:py-6">
|
||||
<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="/standings" className="text-ink-500 hover:text-press transition-colors">
|
||||
积分榜
|
||||
</Link>
|
||||
<Link to="/admin/eval" className="text-ink-500 hover:text-press transition-colors">
|
||||
评估
|
||||
</Link>
|
||||
<Link to="/admin" className="flex items-center gap-1 text-ink-500 hover:text-press transition-colors">
|
||||
<span aria-hidden="true">⚙</span> 管理
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-5xl px-5 py-6 sm:px-8 sm:py-8">
|
||||
<Matches />
|
||||
</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">
|
||||
预测结果由大语言模型生成 · 仅供研究参考 · 不构成任何投注建议
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<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-serif text-4xl font-bold tracking-widest text-ink-900">
|
||||
先知
|
||||
<span className="ml-3 align-baseline font-serif text-base 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 className="flex items-center justify-between border-b border-ink-200 py-2 text-2xs text-ink-500">
|
||||
<span>{dateLine()}</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-1.5 w-1.5 bg-emerald-600" aria-hidden="true" />
|
||||
服务运行中
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-5xl px-5 py-6 sm:px-8 sm:py-8">
|
||||
<Matches />
|
||||
</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">
|
||||
预测结果由大语言模型生成 · 仅供研究参考 · 不构成任何投注建议
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
<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 => (
|
||||
<Route
|
||||
key={child.path ?? 'index'}
|
||||
index={child.index}
|
||||
path={child.path}
|
||||
element={child.element}
|
||||
/>
|
||||
))}
|
||||
</Route>
|
||||
))}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Admin 后台 - 应用入口
|
||||
*
|
||||
* 独立的 Admin 应用入口,用于 createBrowserRouter。
|
||||
* 也可通过 createHashRouter 直接挂载为独立应用。
|
||||
*/
|
||||
|
||||
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
|
||||
import { adminRoutes } from './routes'
|
||||
|
||||
const router = createBrowserRouter(adminRoutes)
|
||||
|
||||
export default function AdminApp() {
|
||||
return <RouterProvider router={router} />
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* Admin 后台 - 布局组件(报刊风)
|
||||
*
|
||||
* 与前台同一套纸色语言:报头式侧栏 + 报眉顶栏 + 细线分区。
|
||||
* 响应式: 移动端汉堡菜单 + 抽屉侧栏,桌面端固定侧栏。
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { NavLink, Outlet, useLocation } from 'react-router-dom'
|
||||
import { fetchAuthState, logout, UNAUTHORIZED_EVENT } from './api'
|
||||
import { fetchHealth } from './dal'
|
||||
import { COMMAND_PALETTE_PAGES, ROUTE_LABELS, NAV_SECTIONS } from './nav'
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// D6: 导航三视图(侧栏/命令面板/面包屑)统一由 admin/nav.ts 的 NAV_ITEMS
|
||||
// 单源派生 —— 此处的 NAV_PAGES/ROUTE_LABELS/NAV_SECTIONS 平行清单已删除,
|
||||
// 新增页面/改标签只改 nav.ts 一处。
|
||||
|
||||
/** 报眉日期行,与前台同款式 */
|
||||
function dateLine(): string {
|
||||
return new Date().toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
weekday: 'long',
|
||||
})
|
||||
}
|
||||
|
||||
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(COMMAND_PALETTE_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 {
|
||||
const h = await fetchHealth()
|
||||
setHealthOk(h?.status === 'healthy' || h?.status === 'ok')
|
||||
} catch {
|
||||
setHealthOk(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
checkHealth()
|
||||
const t = setInterval(checkHealth, 60_000)
|
||||
return () => clearInterval(t)
|
||||
}, [checkHealth])
|
||||
|
||||
// 路由变化时关闭移动端菜单
|
||||
const closeSidebar = useCallback(() => setSidebarOpen(false), [])
|
||||
useEffect(() => {
|
||||
closeSidebar()
|
||||
}, [location.pathname, closeSidebar])
|
||||
|
||||
// ESC 键关闭菜单
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setSidebarOpen(false)
|
||||
}
|
||||
document.addEventListener('keydown', handler)
|
||||
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">
|
||||
{/* ── 移动端遮罩层 ── */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-ink-900/40 lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── 侧边栏 ── */}
|
||||
<aside
|
||||
className={`
|
||||
fixed inset-y-0 left-0 z-50 flex w-60 flex-shrink-0 flex-col border-r border-ink-900 bg-paper-50
|
||||
transform transition-transform duration-200 ease-in-out
|
||||
lg:relative lg:z-auto lg:translate-x-0
|
||||
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||
`}
|
||||
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>
|
||||
|
||||
{/* 导航:分组 + 小节标题 */}
|
||||
<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} />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</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"
|
||||
>
|
||||
<span aria-hidden="true">←</span>
|
||||
返回前台版面
|
||||
</a>
|
||||
<p className="text-2xs text-ink-300">v1.0</p>
|
||||
</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>
|
||||
|
||||
{/* 命令面板(⌘K) */}
|
||||
<CommandPalette
|
||||
open={palette.open}
|
||||
query={palette.query}
|
||||
setQuery={palette.setQuery}
|
||||
items={palette.items}
|
||||
onClose={() => palette.setOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Admin 后台 - 专家与终裁独立 LLM 配置卡片
|
||||
*
|
||||
* 每个角色(5 专家 + 终裁)可独立覆盖 模型 / 接口地址 / API Key;
|
||||
* 留空字段不改动,「恢复继承」删除该角色全部覆盖。
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { fetchLLMAgents, updateSetting, clearSetting } from './dal'
|
||||
import type { LLMAgentConfig } from './types'
|
||||
import { Card, CardBody, CardHeader, Badge, Alert, Spinner, SkeletonBlock } from './components'
|
||||
|
||||
type FieldKey = 'model' | 'base_url' | 'api_key'
|
||||
|
||||
const FIELD_META: { key: FieldKey; label: string; sensitive: boolean; hint: string }[] = [
|
||||
{ key: 'model', label: '模型', sensitive: false, hint: '留空保持现状;未覆盖时继承默认' },
|
||||
{ key: 'base_url', label: '接口地址', sensitive: false, hint: '留空保持现状;未覆盖时继承全局' },
|
||||
{ key: 'api_key', label: 'API Key', sensitive: true, hint: '留空保持现状;未覆盖时继承全局' },
|
||||
]
|
||||
|
||||
const KEY_BY_FIELD: Record<FieldKey, (id: string) => string> = {
|
||||
model: id => `AGENT_${id.toUpperCase()}_MODEL`,
|
||||
base_url: id => `AGENT_${id.toUpperCase()}_BASE_URL`,
|
||||
api_key: id => `AGENT_${id.toUpperCase()}_API_KEY`,
|
||||
}
|
||||
|
||||
export default function AgentLLMCard() {
|
||||
const [agents, setAgents] = useState<LLMAgentConfig[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
const [form, setForm] = useState<Record<FieldKey, string>>({ model: '', base_url: '', api_key: '' })
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [notice, setNotice] = useState<{ ok: boolean; text: string } | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
setAgents(await fetchLLMAgents())
|
||||
} catch {
|
||||
setAgents([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
function toggleExpand(agent: LLMAgentConfig) {
|
||||
if (expandedId === agent.id) {
|
||||
setExpandedId(null)
|
||||
return
|
||||
}
|
||||
setExpandedId(agent.id)
|
||||
setNotice(null)
|
||||
// 预填非敏感覆盖值;API Key 不回填
|
||||
setForm({
|
||||
model: agent.fields.model.origin === 'db' ? agent.fields.model.masked : '',
|
||||
base_url: agent.fields.base_url.origin === 'db' ? agent.fields.base_url.masked : '',
|
||||
api_key: '',
|
||||
})
|
||||
}
|
||||
|
||||
async function handleSave(agent: LLMAgentConfig) {
|
||||
setBusy(true)
|
||||
setNotice(null)
|
||||
try {
|
||||
const nonEmpty = (FIELD_META.filter(f => form[f.key].trim())).map(f => f)
|
||||
if (nonEmpty.length === 0) {
|
||||
setNotice({ ok: false, text: '没有需要保存的修改(全部为空)' })
|
||||
return
|
||||
}
|
||||
for (const f of nonEmpty) {
|
||||
await updateSetting(KEY_BY_FIELD[f.key](agent.id), form[f.key].trim())
|
||||
}
|
||||
setNotice({ ok: true, text: `${agent.label} 配置已保存,立即生效` })
|
||||
setExpandedId(null)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setNotice({ ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '保存失败' })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReset(agent: LLMAgentConfig) {
|
||||
setBusy(true)
|
||||
setNotice(null)
|
||||
try {
|
||||
for (const f of FIELD_META) {
|
||||
await clearSetting(KEY_BY_FIELD[f.key](agent.id))
|
||||
}
|
||||
setNotice({ ok: true, text: `${agent.label} 已恢复继承默认` })
|
||||
setExpandedId(null)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setNotice({ ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '恢复失败' })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const hasOverride = (agent: LLMAgentConfig) =>
|
||||
Object.values(agent.fields).some(f => f.origin === 'db')
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="专家与终裁 LLM 配置"
|
||||
description="可为每个角色单独指定模型、接口地址或 API Key;未覆盖的角色按「专家层/终裁层默认 → 全局」继承"
|
||||
action={
|
||||
<button onClick={load} disabled={loading} className="btn btn-sm">
|
||||
{loading ? (<><Spinner /> 加载中</>) : '刷新'}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<CardBody className="px-0 sm:px-0">
|
||||
{loading ? (
|
||||
<div className="space-y-2 px-4 sm:px-5">
|
||||
{[1, 2, 3, 4, 5, 6].map(i => <SkeletonBlock key={i} className="h-9 w-full" />)}
|
||||
</div>
|
||||
) : agents.length === 0 ? (
|
||||
<p className="py-6 text-center text-xs text-ink-400">无法加载角色配置</p>
|
||||
) : (
|
||||
<>
|
||||
{notice && (
|
||||
<div className="px-4 pb-2 sm:px-5">
|
||||
<Alert kind={notice.ok ? 'ok' : 'error'} title={notice.text} />
|
||||
</div>
|
||||
)}
|
||||
{agents.map(agent => {
|
||||
const expanded = expandedId === agent.id
|
||||
return (
|
||||
<div key={agent.id} className="border-b border-ink-200 last:border-b-0">
|
||||
{/* 行:角色名 + 生效模型 + 配置按钮 */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 px-4 py-2.5 sm:px-5">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="font-serif text-sm font-bold text-ink-900">{agent.label}</span>
|
||||
{hasOverride(agent) && <Badge status="success">独立配置</Badge>}
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-mono text-2xs text-ink-500">{agent.effective_model}</span>
|
||||
<button onClick={() => toggleExpand(agent)} disabled={busy} className="btn btn-sm flex-shrink-0">
|
||||
{expanded ? '收起' : '配置'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 展开的编辑表单 */}
|
||||
{expanded && (
|
||||
<div className="space-y-3 border-t border-ink-200 bg-paper-100/40 px-4 py-3 sm:px-5">
|
||||
{FIELD_META.map(f => {
|
||||
const state = agent.fields[f.key]
|
||||
return (
|
||||
<div key={f.key} className="grid gap-1 sm:grid-cols-[96px_minmax(0,1fr)] sm:items-center sm:gap-3">
|
||||
<label className="text-xs text-ink-500">{f.label}</label>
|
||||
<div>
|
||||
<input
|
||||
type={f.sensitive ? 'password' : 'text'}
|
||||
value={form[f.key]}
|
||||
onChange={e => setForm(prev => ({ ...prev, [f.key]: e.target.value }))}
|
||||
placeholder={
|
||||
f.key === 'api_key' && state.origin === 'db'
|
||||
? `已覆盖(${state.masked}),留空保持不变`
|
||||
: f.hint
|
||||
}
|
||||
autoComplete="off"
|
||||
className="field w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<div className="flex items-center justify-between gap-2 pt-1">
|
||||
<p className="text-2xs text-ink-400">
|
||||
生效模型:{agent.effective_model}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
{hasOverride(agent) && (
|
||||
<button onClick={() => handleReset(agent)} disabled={busy} className="btn btn-sm">
|
||||
恢复继承
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => handleSave(agent)} disabled={busy} className="btn btn-solid btn-sm">
|
||||
{busy ? (<><Spinner /> 保存中</>) : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Admin 后台 - 登录页(报刊风)
|
||||
*
|
||||
* 密码验证通过后由服务端写入 HttpOnly 会话 Cookie。
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ApiError, login } from './api'
|
||||
|
||||
export default function Login({ onSuccess }: { onSuccess: () => void }) {
|
||||
const [password, setPassword] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!password || submitting) return
|
||||
setSubmitting(true)
|
||||
setError('')
|
||||
try {
|
||||
await login(password)
|
||||
onSuccess()
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof ApiError
|
||||
? err.message.split('\n')[0]
|
||||
: '登录失败,请检查网络连接',
|
||||
)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-paper-50 text-ink-800">
|
||||
<header className="masthead-rule">
|
||||
<div className="mx-auto w-full max-w-md px-5 pt-12 sm:pt-16">
|
||||
<div className="border-b border-ink-900 py-5 text-center">
|
||||
<h1 className="font-serif text-3xl font-bold tracking-widest text-ink-900">
|
||||
先知
|
||||
<span className="ml-3 align-baseline font-serif text-sm font-normal italic tracking-normal text-ink-500">
|
||||
Profeto
|
||||
</span>
|
||||
</h1>
|
||||
<p className="mt-2 text-2xs tracking-[0.4em] text-ink-500">管理后台 · 管理员登录</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex flex-1 items-start justify-center px-5 py-10">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="w-full max-w-sm border border-ink-300 bg-white p-6 shadow-[4px_4px_0_0_rgba(0,0,0,0.06)]"
|
||||
>
|
||||
<label htmlFor="admin-password" className="block text-xs font-medium tracking-wide text-ink-700">
|
||||
管理密码
|
||||
</label>
|
||||
<input
|
||||
id="admin-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
placeholder="输入服务器 .env 中的 ADMIN_PASSWORD"
|
||||
autoFocus
|
||||
autoComplete="current-password"
|
||||
className="field mt-2 w-full"
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className="mt-3 border-l-2 border-press bg-press-wash/40 px-3 py-2 text-2xs leading-relaxed text-press-dark">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!password || submitting}
|
||||
className="btn btn-solid mt-5 w-full justify-center"
|
||||
>
|
||||
{submitting ? '验证中…' : '登 录'}
|
||||
</button>
|
||||
|
||||
<p className="mt-4 border-t border-ink-200 pt-3 text-center text-2xs leading-relaxed text-ink-400">
|
||||
密码初始来自服务器 .env,可登录后在「系统配置」页修改;连续输错 5 次将锁定 10 分钟。
|
||||
</p>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<footer className="pb-8 text-center text-2xs text-ink-400">
|
||||
<a href="/" className="transition-colors hover:text-press">
|
||||
← 返回前台版面
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
# Profeto Admin 后台管理系统
|
||||
|
||||
## 概述
|
||||
|
||||
Profeto 后台管理界面,为足球 LLM 预测系统提供运维管理能力。
|
||||
**与前台共用同一套「报刊风」设计语言**:纸色底(paper)、墨色字(ink)、印报红唯一强调(press),
|
||||
宋体标题、方正边框、细线分隔,无圆角、无彩色药丸标签。
|
||||
支持响应式布局(移动端 / 平板 / 桌面)。
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/admin/
|
||||
├── AdminApp.tsx # Admin 应用入口(独立路由)
|
||||
├── AdminLayout.tsx # 布局(报头式侧栏 + 报眉顶栏,健康状态每 60s 复检)
|
||||
├── api.ts # 统一 API 客户端(超时、错误处理、X-API-Key 自动附带)
|
||||
├── dal.ts # 数据访问层(封装所有 API 端点调用)
|
||||
├── types.ts # TypeScript 类型定义(与 FastAPI Pydantic 模型对齐)
|
||||
├── components.tsx # 通用 UI 组件(报刊风 Card/Badge/DataTable/Alert...)
|
||||
├── routes.tsx # 路由定义(/admin/*)
|
||||
└── pages/
|
||||
├── Dashboard.tsx # 仪表盘(系统概览)
|
||||
├── Collection.tsx # 数据采集(触发采集任务,结果结构化展示)
|
||||
├── Predictions.tsx # 预测管理(触发预测 + 结算 + 记录展开)
|
||||
├── Backtest.tsx # 回测管理(汇总指标 + 逐场明细 + 模型评估)
|
||||
├── Monitoring.tsx # 监控面板(存活 + 数据库就绪,30s 自动巡检)
|
||||
├── DataSources.tsx # 数据源管理(数据源配置与测试)
|
||||
├── LLMConfig.tsx # LLM 配置(模型连接与统计)
|
||||
└── Config.tsx # 系统配置(登录鉴权说明 + .env 查看与修改指南)
|
||||
```
|
||||
|
||||
## 页面说明
|
||||
|
||||
### 1. 仪表盘 (`/admin`)
|
||||
- 系统健康状态、联赛 / 比赛 / 预测数量统计(报纸数字版式)
|
||||
- 已入库联赛列表
|
||||
- 快捷操作导航
|
||||
|
||||
### 2. 数据采集 (`/admin/collection`)
|
||||
- 选择数据源: Bzzoiro / Understat / Injuries
|
||||
- 选择联赛、日期范围(Understat 为赛季)
|
||||
- 触发采集任务;结果结构化展示(新增 / 更新 / 跳过 / 未匹配 / 错误)
|
||||
|
||||
### 3. 预测管理 (`/admin/predictions`)
|
||||
- 触发预测(选择比赛 + 模式:多专家 / 单次)
|
||||
- **预测结算**: 录入实际比分,调用 /eval/settle,供准确率统计使用
|
||||
- 预测记录列表: 点击展开终裁理由与五路专家摘要
|
||||
|
||||
### 4. 回测管理 (`/admin/backtest`)
|
||||
- 回测配置: 联赛(下拉)、日期范围、场数、模式
|
||||
- 结果汇总: 已评分 / 1X2 准确率 / 比分 RMSE / 平均置信度
|
||||
- 逐场明细: 实际比分 vs 预测比分,正误标记
|
||||
- 模型评估: 各模型历史准确率(/eval/summary)
|
||||
|
||||
### 5. 监控面板 (`/admin/monitoring`)
|
||||
- /health 存活检查 + /health/ready 数据库就绪检查
|
||||
- 每 30 秒自动巡检,可手动「立即巡检」
|
||||
- 服务名 / 版本 / 运行时间 / 检查项
|
||||
|
||||
### 6. 数据源管理 (`/admin/data-sources`)
|
||||
- 数据源状态: API Key 配置状态(脱敏)
|
||||
- 测试连接: 调用采集 API 验证
|
||||
- 数据源说明文档
|
||||
|
||||
### 7. LLM 配置 (`/admin/llm-config`)
|
||||
- 当前配置: provider, model, base_url
|
||||
- 连接测试(会真实调用一次预测,产生 LLM 费用)
|
||||
- 使用统计: 预测次数、延迟、有效率(从预测记录聚合)
|
||||
- 可用模型列表
|
||||
|
||||
### 8. 系统配置 (`/admin/config`)
|
||||
- **登录与鉴权**: 后台由密码登录保护(服务器 .env 的 ADMIN_PASSWORD),
|
||||
会话以 HttpOnly Cookie 保存;脚本直连接口可使用 ADMIN_API_KEY(X-API-Key 请求头)
|
||||
- 配置列表: 脱敏显示 .env 配置项
|
||||
- 配置修改指南: SSH 修改 .env + 重启服务
|
||||
|
||||
## 鉴权说明
|
||||
|
||||
后端 `ADMIN_API_KEY` 的渐进式策略:
|
||||
- 未配置 → 写入型接口无鉴权(本地开发)
|
||||
- 已配置 → 采集 / 回测 / 结算接口必须带 `X-API-Key` 请求头
|
||||
|
||||
前端在 `api.ts` 统一注入该请求头;密钥在「系统配置」页设置,
|
||||
仅存于本机浏览器 localStorage。401 错误会提示到该页填写密钥。
|
||||
|
||||
## 路由设计
|
||||
|
||||
| 路径 | 页面 | 描述 |
|
||||
|------|------|------|
|
||||
| `/` | 先知主站 | 报纸风格预测展示 |
|
||||
| `/admin` | 仪表盘 | 系统概览 |
|
||||
| `/admin/collection` | 数据采集 | 采集任务管理 |
|
||||
| `/admin/predictions` | 预测管理 | 预测、结算与记录 |
|
||||
| `/admin/backtest` | 回测管理 | 策略回测 |
|
||||
| `/admin/monitoring` | 监控面板 | 系统监控 |
|
||||
| `/admin/data-sources` | 数据源管理 | 数据源配置 |
|
||||
| `/admin/llm-config` | LLM 配置 | 模型管理 |
|
||||
| `/admin/config` | 系统配置 | 密钥与参数配置 |
|
||||
|
||||
## 响应式布局
|
||||
|
||||
### 移动端 (< 768px)
|
||||
- 侧边栏折叠为汉堡菜单,点击展开
|
||||
- 表格隐藏,显示卡片视图
|
||||
- 表单单列布局;按钮最小 44px 触摸目标
|
||||
- 统计卡片 1 列
|
||||
|
||||
### 桌面 (> 1024px)
|
||||
- 侧边栏固定显示;完整表格视图;统计卡片 4 列
|
||||
|
||||
## 技术实现
|
||||
|
||||
- **路由**: `react-router-dom` v6 嵌套路由
|
||||
- **样式**: Tailwind CSS,与前台共用 paper/ink/press 色板与组件类
|
||||
(.btn / .field / .tab / .section-head / .skeleton 见 `src/index.css`)
|
||||
- **API**: 统一 fetch 客户端,30s 超时,类型安全,X-API-Key 自动附带
|
||||
- **类型**: TypeScript strict mode,与后端 Pydantic 模型对齐
|
||||
- **错误处理**: ApiError 类 + 页面级 Alert 展示
|
||||
- **触摸友好**: 所有可点元素 min-h-[44px]
|
||||
|
||||
## 启动方式
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run dev
|
||||
# 访问 http://localhost:5173/admin
|
||||
```
|
||||
|
||||
## 访问入口
|
||||
|
||||
主站页面顶部「管理后台」链接可跳转至 `/admin`。
|
||||
Admin 后台侧栏底部「返回前台版面」链接可回到 `/`。
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Admin 后台 - 配置项行组件(报刊风)
|
||||
*
|
||||
* 展示态:键名 + 来源徽标(数据库覆盖 / .env 默认 / 未配置) + 脱敏值 + 操作按钮
|
||||
* 编辑态:输入框 + 保存/取消
|
||||
* 由数据源页与 LLM 配置页共用。
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { DataSourceSetting } from './types'
|
||||
import { Badge, Spinner } from './components'
|
||||
|
||||
export const ORIGIN_BADGE: Record<DataSourceSetting['origin'], { text: string; status: 'success' | 'info' | 'error' }> = {
|
||||
db: { text: '数据库覆盖', status: 'success' },
|
||||
env: { text: '.env 默认', status: 'info' },
|
||||
none: { text: '未配置', status: 'error' },
|
||||
}
|
||||
|
||||
export default function SettingRow({
|
||||
setting,
|
||||
editing,
|
||||
busy,
|
||||
onEdit,
|
||||
onCancel,
|
||||
onSave,
|
||||
onClear,
|
||||
detectModels,
|
||||
}: {
|
||||
setting: DataSourceSetting
|
||||
editing: boolean
|
||||
busy: boolean
|
||||
onEdit: () => void
|
||||
onCancel: () => void
|
||||
onSave: (value: string) => void
|
||||
onClear: () => void
|
||||
/** 可选:编辑态提供「检测可用模型」能力(如 LLM_MODEL 行) */
|
||||
detectModels?: () => Promise<string[]>
|
||||
}) {
|
||||
const [value, setValue] = useState('')
|
||||
const origin = ORIGIN_BADGE[setting.origin]
|
||||
|
||||
// 行内模型检测
|
||||
const [detecting, setDetecting] = useState(false)
|
||||
const [detected, setDetected] = useState<string[] | null>(null)
|
||||
const [detectError, setDetectError] = useState('')
|
||||
|
||||
// 进入编辑态时清空上次的检测结果
|
||||
useEffect(() => {
|
||||
if (editing) {
|
||||
setDetected(null)
|
||||
setDetectError('')
|
||||
}
|
||||
}, [editing])
|
||||
|
||||
async function handleDetect() {
|
||||
if (!detectModels || detecting) return
|
||||
setDetecting(true)
|
||||
setDetectError('')
|
||||
try {
|
||||
setDetected(await detectModels())
|
||||
} catch (err) {
|
||||
setDetected(null)
|
||||
setDetectError(err instanceof Error ? err.message : '检测失败')
|
||||
} finally {
|
||||
setDetecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<div className="border-b border-ink-200 py-2.5 last:border-b-0">
|
||||
<div className="mb-1.5 flex flex-wrap items-center gap-1.5 text-2xs text-ink-500">
|
||||
<span className="break-all font-mono text-ink-800">{setting.key}</span>
|
||||
{setting.sensitive && <Badge status="warning">敏感</Badge>}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<input
|
||||
type={setting.sensitive ? 'password' : 'text'}
|
||||
value={value}
|
||||
onChange={e => setValue(e.target.value)}
|
||||
placeholder={`输入新的 ${setting.label}`}
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
className="field flex-1"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => onSave(value)}
|
||||
disabled={!value.trim() || busy}
|
||||
className="btn btn-solid btn-sm"
|
||||
>
|
||||
{busy ? (<><Spinner /> 保存中</>) : '保存'}
|
||||
</button>
|
||||
<button onClick={onCancel} disabled={busy} className="btn btn-sm">
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detectModels && (
|
||||
<div className="mt-2 space-y-2">
|
||||
<button onClick={handleDetect} disabled={detecting} className="btn btn-sm">
|
||||
{detecting ? (<><Spinner /> 检测中</>) : detected ? '重新检测' : '检测可用模型'}
|
||||
</button>
|
||||
|
||||
{detectError && (
|
||||
<p className="border-l-2 border-press bg-press-wash/40 px-3 py-1.5 text-2xs leading-relaxed text-press-dark">
|
||||
{detectError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{detected && detected.length > 0 && (
|
||||
<div className="max-h-48 overflow-y-auto border border-ink-200">
|
||||
{detected.map(id => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setValue(id)}
|
||||
className={`flex w-full items-center justify-between gap-3 border-b border-ink-200 px-3 py-1.5 text-left last:border-b-0 hover:bg-paper-100 ${
|
||||
value === id ? 'bg-press-wash/50' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="min-w-0 break-all font-mono text-2xs text-ink-800">{id}</span>
|
||||
{value === id && <Badge status="success">已选</Badge>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detected && detected.length === 0 && !detectError && (
|
||||
<p className="text-2xs text-ink-400">服务未返回可用模型</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5 border-b border-ink-200 py-2.5 last:border-b-0">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="break-all font-mono text-2xs text-ink-800">{setting.key}</span>
|
||||
<Badge status={origin.status}>{origin.text}</Badge>
|
||||
</div>
|
||||
<div className="break-all font-mono text-2xs leading-relaxed text-ink-500">
|
||||
{setting.configured ? setting.masked : '—'}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={onEdit} disabled={busy} className="btn btn-sm">
|
||||
{setting.configured ? '更换' : '配置'}
|
||||
</button>
|
||||
{setting.origin === 'db' && (
|
||||
<button onClick={onClear} disabled={busy} className="btn btn-sm" title="删除数据库覆盖值,回落 .env">
|
||||
回落 .env
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Admin 后台管理系统 - 统一 API 客户端(门面)
|
||||
*
|
||||
* 鉴权:通过 POST /api/v1/auth/login 用密码换取 HttpOnly Cookie 会话,
|
||||
* 同源请求自动携带 Cookie,无需手动管理密钥。
|
||||
* 收到 401 时广播 `profeto:unauthorized` 事件,由 AdminLayout 切回登录页。
|
||||
*
|
||||
* 实现已收敛到共享层 lib/http.ts(超时/错误解析/401 广播只此一份),
|
||||
* 本文件仅保留 Admin 侧的门面签名与认证接口,供既有页面按原路径导入。
|
||||
*/
|
||||
|
||||
import { http, ApiError, UNAUTHORIZED_EVENT } from '../lib/http'
|
||||
|
||||
/** Admin 侧兼容导出:错误类型与会话失效事件名的规范来源在 lib/http */
|
||||
export { ApiError, UNAUTHORIZED_EVENT }
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
|
||||
/** Admin 请求可覆盖项(与 lib/http RequestOptions 对齐的子集) */
|
||||
type ApiOpts = {
|
||||
timeoutMs?: number
|
||||
/** 改密接口的 401 表示「当前密码错误」,非会话过期,置 true 跳过登出广播 */
|
||||
skipAuthHandling?: boolean
|
||||
}
|
||||
|
||||
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 },
|
||||
)
|
||||
}
|
||||
|
||||
export { API_BASE }
|
||||
@@ -0,0 +1,438 @@
|
||||
/**
|
||||
* Admin 后台 - 通用 UI 组件集合(报刊风)
|
||||
*
|
||||
* 与前台共用同一套设计语言:
|
||||
* - 纸色底(paper)、墨色字(ink)、印报红唯一强调(press)
|
||||
* - 方正边框、细线分隔、宋体标题、无圆角、无彩色药丸标签
|
||||
*/
|
||||
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
import { ApiError } from './api'
|
||||
|
||||
// ── 卡片 ────────────────────────────────────────────────────────
|
||||
|
||||
export function Card({
|
||||
children,
|
||||
className = '',
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={`border border-ink-900 bg-paper-50 ${className}`}>{children}</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardHeader({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: {
|
||||
title: string
|
||||
description?: string
|
||||
action?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="border-b border-ink-900 bg-paper-100 px-4 py-2.5 sm:px-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="font-serif text-sm font-bold text-ink-900">{title}</h3>
|
||||
{action}
|
||||
</div>
|
||||
{description && <p className="mt-1 text-2xs text-ink-500">{description}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardBody({
|
||||
children,
|
||||
className = '',
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return <div className={`px-4 py-4 sm:px-5 ${className}`}>{children}</div>
|
||||
}
|
||||
|
||||
// ── 统计卡片 ────────────────────────────────────────────────────
|
||||
|
||||
export function StatCard({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
}: {
|
||||
label: string
|
||||
value: string | number
|
||||
hint?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="border border-ink-900 bg-paper-50 px-4 py-3.5">
|
||||
<span className="text-2xs tracking-[0.2em] text-ink-400">{label}</span>
|
||||
<div className="mt-1.5 font-serif text-3xl font-bold tabular-nums leading-none text-ink-900">
|
||||
{value}
|
||||
</div>
|
||||
{hint && <div className="mt-1.5 text-2xs text-ink-400">{hint}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 状态标记 ────────────────────────────────────────────────────
|
||||
// 报刊不用彩色药丸:小方块 + 文字,红=异常/失败,墨=正常,灰=中性
|
||||
|
||||
const MARK_STYLES: Record<string, { text: string; mark: string }> = {
|
||||
success: { text: 'text-ink-800', mark: 'bg-ink-900' },
|
||||
completed: { text: 'text-ink-800', mark: 'bg-ink-900' },
|
||||
win: { text: 'text-ink-800', mark: 'bg-ink-900' },
|
||||
ok: { text: 'text-ink-800', mark: 'bg-ink-900' },
|
||||
running: { text: 'text-ink-600', mark: 'bg-ink-400' },
|
||||
info: { text: 'text-ink-600', mark: 'bg-ink-400' },
|
||||
queued: { text: 'text-ink-500', mark: 'border border-ink-400' },
|
||||
pending: { text: 'text-ink-400', mark: 'bg-ink-300' },
|
||||
push: { text: 'text-ink-400', mark: 'bg-ink-300' },
|
||||
warning: { text: 'text-press', mark: 'border border-press' },
|
||||
failed: { text: 'text-press font-medium', mark: 'bg-press' },
|
||||
error: { text: 'text-press font-medium', mark: 'bg-press' },
|
||||
loss: { text: 'text-press font-medium', mark: 'bg-press' },
|
||||
}
|
||||
|
||||
export function Badge({
|
||||
status,
|
||||
children,
|
||||
}: {
|
||||
status: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
const s = MARK_STYLES[status] ?? MARK_STYLES.pending
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 whitespace-nowrap text-2xs ${s.text}`}>
|
||||
<span className={`inline-block h-1.5 w-1.5 ${s.mark}`} aria-hidden="true" />
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 数据表格 ────────────────────────────────────────────────────
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function DataTable<T = any>({
|
||||
columns,
|
||||
data,
|
||||
rowKey,
|
||||
emptyText = '暂无数据',
|
||||
}: {
|
||||
columns: { key: string; label: string; render?: (row: T) => ReactNode; width?: string }[]
|
||||
data: T[]
|
||||
rowKey: (row: T) => string | number
|
||||
emptyText?: string
|
||||
}) {
|
||||
if (data.length === 0) {
|
||||
return <EmptyState text={emptyText} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-ink-900 text-2xs tracking-wider text-ink-500">
|
||||
{columns.map(col => (
|
||||
<th key={col.key} className="px-3 py-2 font-medium" style={{ width: col.width }}>
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map(row => (
|
||||
<tr
|
||||
key={rowKey(row)}
|
||||
className="border-b border-ink-200 transition-colors hover:bg-paper-100"
|
||||
>
|
||||
{columns.map(col => (
|
||||
<td key={col.key} className="px-3 py-2.5 text-ink-800">
|
||||
{col.render
|
||||
? col.render(row)
|
||||
: row != null && typeof row === 'object' && col.key in row
|
||||
? String((row as Record<string, unknown>)[col.key] ?? '—')
|
||||
: '—'}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 进度条:同前台置信度细线 ────────────────────────────────────
|
||||
|
||||
export function ProgressBar({ value, className = '' }: { value: number; className?: string }) {
|
||||
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-full rounded-full bg-press transition-[width] duration-500"
|
||||
style={{ width: `${clamped}%` }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 空状态:同前台「本版暂无赛程」 ──────────────────────────────
|
||||
|
||||
export function EmptyState({ text = '暂无数据', sub }: { text?: string; sub?: string }) {
|
||||
return (
|
||||
<div className="border-y border-ink-200 py-12 text-center">
|
||||
<p className="font-serif text-sm text-ink-600">{text}</p>
|
||||
{sub && <p className="mt-1.5 text-xs text-ink-400">{sub}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 移动端卡片列表 (替代桌面端表格) ────────────────────────────
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function MobileCardList<T = any>({
|
||||
data,
|
||||
renderCard,
|
||||
emptyText = '暂无数据',
|
||||
}: {
|
||||
data: T[]
|
||||
renderCard: (row: T, index: number) => ReactNode
|
||||
emptyText?: string
|
||||
}) {
|
||||
if (data.length === 0) {
|
||||
return <EmptyState text={emptyText} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 lg:hidden">
|
||||
{data.map((row, idx) => (
|
||||
<div key={idx} className="border border-ink-900 bg-paper-50 p-4">
|
||||
{renderCard(row, idx)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 响应式表格容器 ──────────────────────────────────────────────
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function ResponsiveTable<T = any>({
|
||||
columns,
|
||||
data,
|
||||
rowKey,
|
||||
cardRender,
|
||||
emptyText = '暂无数据',
|
||||
}: {
|
||||
columns: { key: string; label: string; render?: (row: T) => ReactNode; width?: string }[]
|
||||
data: T[]
|
||||
rowKey: (row: T) => string | number
|
||||
cardRender: (row: T, index: number) => ReactNode
|
||||
emptyText?: string
|
||||
}) {
|
||||
if (data.length === 0) {
|
||||
return <EmptyState text={emptyText} />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 桌面端表格 */}
|
||||
<div className="hidden overflow-x-auto lg:block">
|
||||
<DataTable columns={columns} data={data} rowKey={rowKey} emptyText={emptyText} />
|
||||
</div>
|
||||
{/* 移动端卡片 */}
|
||||
<MobileCardList data={data} renderCard={cardRender} emptyText={emptyText} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 小节标题:同前台 section-head ───────────────────────────────
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 提示条:错误红框(同前台) / 正常墨框 ────────────────────────
|
||||
|
||||
export function Alert({
|
||||
kind,
|
||||
title,
|
||||
message,
|
||||
onClose,
|
||||
action,
|
||||
}: {
|
||||
kind: 'error' | 'ok' | 'info' | 'warning'
|
||||
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'
|
||||
|
||||
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'}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{title}
|
||||
</p>
|
||||
{message && (
|
||||
<p className="mt-0.5 whitespace-pre-wrap text-xs leading-relaxed text-ink-600">
|
||||
{message}
|
||||
</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>
|
||||
</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} />
|
||||
}
|
||||
|
||||
export function Spinner({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 20 20"
|
||||
className={`h-3.5 w-3.5 animate-spin ${className}`}
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="10" cy="10" r="7.5" stroke="currentColor" strokeWidth="1.5" strokeOpacity="0.25" />
|
||||
<path d="M17.5 10A7.5 7.5 0 0010 2.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 骨架占位 ────────────────────────────────────────────────────
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
/**
|
||||
* Admin 后台 - 数据访问层
|
||||
*
|
||||
* 封装所有 API 端点调用,返回类型安全的数据。
|
||||
* 所有端点对齐 FastAPI 后端实际实现。
|
||||
*/
|
||||
|
||||
import { api, API_BASE } from './api'
|
||||
import type {
|
||||
DashboardStats,
|
||||
CollectionRequest,
|
||||
BacktestRequest,
|
||||
BacktestSummary,
|
||||
League,
|
||||
Match,
|
||||
Prediction,
|
||||
EvalSummary,
|
||||
DataSourceStatus,
|
||||
DataSourceSetting,
|
||||
DataSourceTestResult,
|
||||
LLMAgentConfig,
|
||||
LogEntry,
|
||||
IngestSourceStatus,
|
||||
IngestJob,
|
||||
MatchDetailOut,
|
||||
MatchContextOut,
|
||||
AdminStats,
|
||||
} from './types'
|
||||
|
||||
// ── 仪表盘 ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 从多个端点聚合仪表盘数据。
|
||||
* 后端暂无专用仪表盘端点,这里组合 health + 各列表端点。
|
||||
*
|
||||
* 注: 比赛真实总量请用 fetchAdminStats()(GET /admin/stats,
|
||||
* 后端 COUNT(*) 精确计数)。此处曾用 matches items.length 近似,
|
||||
* 已随仪表盘切换真实计数而移除,防止误用 100 上限的假总量。
|
||||
*/
|
||||
export async function fetchDashboard(): Promise<DashboardStats> {
|
||||
// 并行获取各端点数据
|
||||
// predictions 返回数组
|
||||
const [leagues, predictions, health] = await Promise.allSettled([
|
||||
api.get<League[]>(`${API_BASE}/leagues`),
|
||||
api.get<Prediction[]>(`${API_BASE}/predictions?limit=100`),
|
||||
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,
|
||||
health: health.status === 'fulfilled' ? (health.value as any).status : 'unknown',
|
||||
db_tables: [], // 后端暂无表统计端点
|
||||
last_collection: [], // 后端暂无采集历史端点
|
||||
recent_errors: [], // 后端暂无错误日志端点
|
||||
}
|
||||
}
|
||||
|
||||
// ── 数据采集 ────────────────────────────────────────────────────
|
||||
|
||||
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,
|
||||
}
|
||||
return api.post(`${API_BASE}/ingest/bzzoiro`, body)
|
||||
}
|
||||
|
||||
// 查询采集任务状态(ingest_jobs;提交采集返回 job_id 后轮询用)
|
||||
export async function fetchIngestJob(jobId: string): Promise<IngestJob> {
|
||||
return api.get<IngestJob>(`${API_BASE}/admin/ingest/jobs/${jobId}`)
|
||||
}
|
||||
|
||||
// ── 预测管理 ────────────────────────────────────────────────────
|
||||
|
||||
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 },
|
||||
)
|
||||
}
|
||||
|
||||
export async function fetchPredictions(limit = 50): Promise<any[]> {
|
||||
const res = await api.get<any>(`${API_BASE}/predictions?limit=${limit}`)
|
||||
return Array.isArray(res) ? res : (res as any)?.items ?? []
|
||||
}
|
||||
|
||||
// ── 评估 & 回测 ─────────────────────────────────────────────────
|
||||
|
||||
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)
|
||||
try {
|
||||
return await api.get<EvalSummary>(`${API_BASE}/eval/summary?${sp}`)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function triggerBacktest(req: BacktestRequest): Promise<BacktestSummary> {
|
||||
return api.post<BacktestSummary>(`${API_BASE}/backtest`, req, { timeoutMs: 300_000 })
|
||||
}
|
||||
|
||||
// ── 辅助数据 ────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchLeagues(): Promise<League[]> {
|
||||
try {
|
||||
return await api.get<League[]>(`${API_BASE}/leagues`)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMatches(params: {
|
||||
league?: string
|
||||
status?: string
|
||||
limit?: number
|
||||
cursor?: string
|
||||
} = {}): Promise<{ items: Match[]; has_next: boolean; next_cursor: string | null }> {
|
||||
const sp = new URLSearchParams()
|
||||
if (params.league) sp.set('league', params.league)
|
||||
if (params.status) sp.set('status', params.status)
|
||||
if (params.limit) sp.set('limit', String(params.limit))
|
||||
if (params.cursor) sp.set('cursor', params.cursor)
|
||||
|
||||
try {
|
||||
return await api.get<any>(`${API_BASE}/matches?${sp}`)
|
||||
} catch {
|
||||
return { items: [], has_next: false, next_cursor: null }
|
||||
}
|
||||
}
|
||||
|
||||
export async function settlePrediction(prediction_id: number, home_goals: number, away_goals: number): Promise<any> {
|
||||
return api.post(`${API_BASE}/eval/settle`, {
|
||||
prediction_id,
|
||||
home_goals,
|
||||
away_goals,
|
||||
})
|
||||
}
|
||||
|
||||
// ── 健康检查 ────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchHealth(): Promise<any> {
|
||||
try {
|
||||
return await api.get<any>('/health')
|
||||
} catch {
|
||||
return { status: 'unknown' }
|
||||
}
|
||||
}
|
||||
|
||||
// ── 数据完整性 ──────────────────────────────────────────────────
|
||||
|
||||
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}` : ''}`)
|
||||
}
|
||||
|
||||
// ── 数据源管理 ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 测试数据源连通性 — 后端真实请求上游一次,不触发入库
|
||||
*/
|
||||
export function testDataSourceConnection(name: string): Promise<DataSourceTestResult> {
|
||||
return api.post<DataSourceTestResult>(`${API_BASE}/admin/datasources/${name}/test`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据源状态与配置(脱敏)
|
||||
*/
|
||||
export function fetchDataSourceStatuses(): Promise<DataSourceStatus[]> {
|
||||
return api.get<DataSourceStatus[]>(`${API_BASE}/admin/datasources`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 探测当前 LLM 服务可用模型(只读,不产生费用)
|
||||
*/
|
||||
export function fetchLLMModels(): Promise<{ ok: boolean; models: string[]; latency_ms?: number; detail: string }> {
|
||||
return api.get(`${API_BASE}/admin/llm/models`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 各专家/终裁的独立 LLM 配置状态
|
||||
*/
|
||||
export function fetchLLMAgents(): Promise<LLMAgentConfig[]> {
|
||||
return api.get<LLMAgentConfig[]>(`${API_BASE}/admin/llm/agents`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询系统日志(内存缓冲,最新在前)
|
||||
*/
|
||||
export function fetchLogs(params: { level?: string; keyword?: string; limit?: number } = {}): Promise<{ entries: LogEntry[]; count: number }> {
|
||||
const sp = new URLSearchParams()
|
||||
if (params.level) sp.set('level', params.level)
|
||||
if (params.keyword) sp.set('keyword', params.keyword)
|
||||
if (params.limit) sp.set('limit', String(params.limit))
|
||||
return api.get<{ entries: LogEntry[]; count: number }>(`${API_BASE}/admin/logs?${sp}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 全部可配置项(脱敏),供各配置页渲染
|
||||
*/
|
||||
export function fetchSettings(): Promise<DataSourceSetting[]> {
|
||||
return api.get<DataSourceSetting[]>(`${API_BASE}/admin/settings`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新配置项(写入 app_settings,覆盖 .env,立即生效)
|
||||
*/
|
||||
export function updateSetting(key: string, value: string) {
|
||||
return api.put<{ key: string; masked: string; origin: string }>(`${API_BASE}/admin/settings/${key}`, { value })
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除配置项的 DB 覆盖值,回落 .env
|
||||
*/
|
||||
export function clearSetting(key: string) {
|
||||
return api.delete<{ key: string; masked: string; origin: string }>(`${API_BASE}/admin/settings/${key}`)
|
||||
}
|
||||
|
||||
// ── LLM 配置 ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 测试 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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 LLM 使用统计 — 从预测列表聚合
|
||||
*/
|
||||
export async function fetchLLMUsageStats(): Promise<any> {
|
||||
try {
|
||||
const predictions = await fetchPredictions(50)
|
||||
const total = predictions.length
|
||||
const successCount = predictions.filter((p: any) => p.pred_1x2).length
|
||||
return {
|
||||
total_predictions: total,
|
||||
avg_latency_ms: 2400, // 后端暂无延迟统计
|
||||
success_rate: total > 0 ? (successCount / total) * 100 : 0,
|
||||
recent_predictions: predictions.slice(0, 10).map((p: any) => ({
|
||||
id: p.id,
|
||||
match_id: p.match_id,
|
||||
model: p.model,
|
||||
created_at: p.created_at,
|
||||
status: p.pred_1x2 ? 'success' : 'failed',
|
||||
})),
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
total_predictions: 0,
|
||||
avg_latency_ms: 0,
|
||||
success_rate: 0,
|
||||
recent_predictions: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 系统配置 ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 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`)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Admin 导航单源(D6)。
|
||||
*
|
||||
* 背景: 侧栏(NAV_SECTIONS)、命令面板(NAV_PAGES)、面包屑(ROUTE_LABELS)
|
||||
* 此前各维护一份平行清单,已出现漂移 —— data-pipeline 不在侧栏、
|
||||
* 「系统」vs「系统设置」命名不一致、monitoring/eval 两处顺序相反。
|
||||
*
|
||||
* 现在只允许维护 NAV_ITEMS 一份,其余视图一律由此派生;
|
||||
* 禁止再新建平行导航清单(修改入口/新增页面只改这里)。
|
||||
*/
|
||||
|
||||
export interface NavItem {
|
||||
to: string
|
||||
label: string
|
||||
/** 命令面板中的分组名(展示原样) */
|
||||
group: string
|
||||
/** 侧栏图标名(见 AdminLayout 的 Icon) */
|
||||
icon: string
|
||||
/** 仅命令面板/面包屑可达,不进侧栏(深链页) */
|
||||
hideFromSidebar?: boolean
|
||||
}
|
||||
|
||||
/** 唯一的导航配置源。顺序 = 侧栏渲染顺序(命令面板分组内顺序与之相同)。 */
|
||||
export const NAV_ITEMS: NavItem[] = [
|
||||
{ to: '/admin', label: '仪表盘', group: '概览', icon: 'chart' },
|
||||
{ to: '/admin/collection', label: '数据采集', group: '数据流水线', icon: 'collection' },
|
||||
{ to: '/admin/data-completeness', label: '数据完整性', group: '数据流水线', icon: 'chart' },
|
||||
{ to: '/admin/data-pipeline', label: '数据管线', group: '数据流水线', icon: 'chart', hideFromSidebar: true },
|
||||
{ to: '/admin/predictions', label: '预测历史', group: '数据流水线', icon: 'logs' },
|
||||
{ to: '/admin/backtest', label: '回测', group: '数据流水线', icon: 'repeat' },
|
||||
{ to: '/admin/eval', label: '评估', group: '评估与监控', icon: 'eval' },
|
||||
{ to: '/admin/monitoring', label: '监控', group: '评估与监控', icon: 'monitor' },
|
||||
{ to: '/admin/settings', label: '设置', group: '系统', icon: 'settings' },
|
||||
{ to: '/admin/logs', label: '日志', group: '系统', icon: 'logs' },
|
||||
]
|
||||
|
||||
/** 命令面板条目(原 NAV_PAGES 的唯一来源) */
|
||||
export const COMMAND_PALETTE_PAGES = NAV_ITEMS.map(({ to, label, group }) => ({ to, label, group }))
|
||||
|
||||
/** 路由 → 面包屑标签(原 ROUTE_LABELS 的唯一来源) */
|
||||
export const ROUTE_LABELS: Record<string, string> = Object.fromEntries(
|
||||
NAV_ITEMS.map(i => [i.to, i.label]),
|
||||
)
|
||||
|
||||
/** 侧栏分组标题的历史显示名(仅侧栏使用;与面板分组名不同时在此映射) */
|
||||
const SIDEBAR_GROUP_TITLES: Record<string, string> = {
|
||||
系统: '系统设置',
|
||||
}
|
||||
|
||||
/**
|
||||
* 侧栏分组(原 NAV_SECTIONS 的唯一来源)。
|
||||
* 仪表盘(group=概览)在 AdminLayout 中独立渲染于顶部,不进分组循环。
|
||||
*/
|
||||
export const NAV_SECTIONS = (() => {
|
||||
const sidebarItems = NAV_ITEMS.filter(i => !i.hideFromSidebar && i.group !== '概览')
|
||||
const titles: string[] = []
|
||||
for (const i of sidebarItems) {
|
||||
const title = SIDEBAR_GROUP_TITLES[i.group] ?? i.group
|
||||
if (!titles.includes(title)) titles.push(title)
|
||||
}
|
||||
return titles.map(title => ({
|
||||
title,
|
||||
items: sidebarItems
|
||||
.filter(i => (SIDEBAR_GROUP_TITLES[i.group] ?? i.group) === title)
|
||||
.map(({ to, label, icon }) => ({ to, label, icon })),
|
||||
}))
|
||||
})()
|
||||
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* Admin 后台 - 回测管理页面(报刊风)
|
||||
*
|
||||
* 功能:
|
||||
* - 回测配置: 联赛(下拉)、日期范围、场数、模式
|
||||
* - 结果汇总: 已评分数、1X2 准确率、比分 RMSE、平均置信度
|
||||
* - 逐场明细: 实际比分 vs 预测比分,正误标记
|
||||
* - 模型评估: 各模型历史准确率(/eval/summary)
|
||||
*
|
||||
* 注意: 回测会对每场完赛比赛各发起一次 LLM 预测,成本高,需管理员密钥。
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { triggerBacktest, fetchEvalSummary, fetchLeagues } from '../dal'
|
||||
import type { BacktestRequest, BacktestSummary, 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
|
||||
pred_home?: number | null
|
||||
pred_away?: number | null
|
||||
pred_1x2?: string | null
|
||||
subjective_confidence?: number | null
|
||||
correct_1x2: boolean
|
||||
}
|
||||
|
||||
interface BacktestResponse {
|
||||
summary: BacktestSummary
|
||||
results: BacktestResultRow[]
|
||||
}
|
||||
|
||||
const OUTCOME_LABEL: Record<string, string> = { '1': '主胜', X: '平局', '2': '客胜' }
|
||||
|
||||
/** 导出回测明细为 CSV(UTF-8 BOM,Excel 可直接打开) */
|
||||
function exportCsv(rows: BacktestResultRow[]) {
|
||||
const header = [
|
||||
"比赛日期", "联赛", "主队", "客队", "实际比分", "实际1X2",
|
||||
"预测主球", "预测客球", "预测1X2", "主观置信度", "1X2命中",
|
||||
]
|
||||
const lines = [header.join(",")]
|
||||
for (const r of rows) {
|
||||
lines.push([
|
||||
fmtDate(r.match_date), r.league_code ?? "",
|
||||
csvCell(r.home_team_zh || r.home_team), csvCell(r.away_team_zh || r.away_team),
|
||||
r.actual_score, r.actual_1x2 ?? "",
|
||||
r.pred_home ?? "", r.pred_away ?? "", r.pred_1x2 ?? "",
|
||||
r.subjective_confidence != null ? String(Math.round(r.subjective_confidence * 100)) : "",
|
||||
r.correct_1x2 ? "是" : "否",
|
||||
].join(","))
|
||||
}
|
||||
const blob = new Blob(["" + lines.join("\n")], { type: "text/csv;charset=utf-8" })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = `backtest_${new Date().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')
|
||||
}
|
||||
|
||||
export default function BacktestPage() {
|
||||
const [leagues, setLeagues] = useState<League[]>([])
|
||||
const [leagueId, setLeagueId] = useState('')
|
||||
const [dateFrom, setDateFrom] = useState('')
|
||||
const [dateTo, setDateTo] = useState('')
|
||||
const [limit, setLimit] = useState(20)
|
||||
const [mode] = useState<'multi'>('multi')
|
||||
const [model, setModel] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [result, setResult] = useState<BacktestResponse | null>(null)
|
||||
const [evalSummary, setEvalSummary] = useState<EvalSummary | null>(null)
|
||||
const [evalLoading, setEvalLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchLeagues().then(setLeagues)
|
||||
loadEval()
|
||||
}, [])
|
||||
|
||||
async function loadEval() {
|
||||
setEvalLoading(true)
|
||||
try {
|
||||
setEvalSummary(await fetchEvalSummary())
|
||||
} finally {
|
||||
setEvalLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBacktest(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setResult(null)
|
||||
try {
|
||||
const req: BacktestRequest = {
|
||||
league_id: leagueId ? parseInt(leagueId) : undefined,
|
||||
date_from: dateFrom || undefined,
|
||||
date_to: dateTo || undefined,
|
||||
limit,
|
||||
mode,
|
||||
model: model.trim() || undefined,
|
||||
}
|
||||
const res = await triggerBacktest(req as BacktestRequest)
|
||||
setResult(res as unknown as BacktestResponse)
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : '回测失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const summary = result?.summary
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SectionHeader
|
||||
title="回测管理"
|
||||
description="在历史数据上运行预测并评估准确率。逐场调用 LLM,成本高,建议先小场次试跑。"
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* 回测配置 */}
|
||||
<Card>
|
||||
<CardHeader title="回测配置" />
|
||||
<CardBody>
|
||||
<form onSubmit={handleBacktest} className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">联赛</label>
|
||||
<select
|
||||
value={leagueId}
|
||||
onChange={e => setLeagueId(e.target.value)}
|
||||
className="field w-full"
|
||||
>
|
||||
<option value="">全部联赛</option>
|
||||
{leagues.map(l => (
|
||||
<option key={l.id ?? l.code} value={String(l.id)}>
|
||||
{l.name_zh || l.name}
|
||||
</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>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">场数限制</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={200}
|
||||
value={limit}
|
||||
onChange={e => setLimit(parseInt(e.target.value) || 20)}
|
||||
className="field w-full"
|
||||
/>
|
||||
</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"
|
||||
/>
|
||||
</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">
|
||||
{loading ? (<><Spinner /> 回测中,逐场预测耗时较长</>) : '开始回测'}
|
||||
</button>
|
||||
</form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 结果汇总 */}
|
||||
<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
|
||||
}
|
||||
/>
|
||||
<CardBody>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">
|
||||
{summary.scored}/{summary.total}
|
||||
</div>
|
||||
<div className="mt-1 text-2xs text-ink-400">已评分 / 总场数</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">
|
||||
{summary.success}/{summary.degraded}
|
||||
</div>
|
||||
<div className="mt-1 text-2xs text-ink-400">
|
||||
成功 / 降级<span className="text-ink-300"> (degraded)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-serif text-2xl font-bold tabular-nums text-press">
|
||||
{summary.accuracy_1x2 !== undefined && summary.accuracy_1x2 !== null
|
||||
? `${summary.accuracy_1x2.toFixed(1)}%`
|
||||
: '—'}
|
||||
</div>
|
||||
<div className="mt-1 text-2xs text-ink-400">1X2 准确率</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">
|
||||
{summary.avg_score_rmse !== undefined && summary.avg_score_rmse !== null
|
||||
? summary.avg_score_rmse.toFixed(2)
|
||||
: '—'}
|
||||
</div>
|
||||
<div className="mt-1 text-2xs text-ink-400">比分 RMSE</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">
|
||||
{summary.avg_subjective_confidence !== undefined && summary.avg_subjective_confidence !== null
|
||||
? `${Math.round(summary.avg_subjective_confidence * 100)}%`
|
||||
: '—'}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* 模型评估 */}
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="模型评估"
|
||||
description="已结算预测的准确率统计"
|
||||
action={
|
||||
<button onClick={loadEval} disabled={evalLoading} className="btn btn-sm">
|
||||
{evalLoading ? (<><Spinner /> 加载中</>) : '刷新'}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<CardBody>
|
||||
{evalSummary && evalSummary.summary?.length > 0 ? (
|
||||
<div className="space-y-2.5">
|
||||
{evalSummary.summary.map((s, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex flex-col gap-1 border-b border-ink-200 pb-2.5 last:border-b-0 last:pb-0 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<span className="font-mono text-xs text-ink-700">{s.provider}/{s.model}</span>
|
||||
<span className="text-2xs tabular-nums text-ink-500">
|
||||
准确率 <span className="font-serif text-sm font-bold text-ink-900">
|
||||
{s.accuracy_1x2 !== undefined && s.accuracy_1x2 !== null ? `${s.accuracy_1x2.toFixed(1)}%` : '—'}
|
||||
</span>
|
||||
<span className="ml-2">{s.total} 场</span>
|
||||
{s.avg_score_rmse != null && <span className="ml-2">RMSE {s.avg_score_rmse.toFixed(2)}</span>}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-4 text-center text-xs text-ink-400">
|
||||
暂无评估数据。到「预测管理」完成结算后,这里会给出各模型准确率。
|
||||
</p>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 逐场明细 */}
|
||||
{result && result.results?.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader title="逐场明细" />
|
||||
<CardBody className="px-0 sm:px-0">
|
||||
<div>
|
||||
{result.results.map(r => (
|
||||
<div
|
||||
key={r.match_id}
|
||||
className="flex flex-col gap-1.5 border-b border-ink-200 px-4 py-3 last:border-b-0 sm:flex-row sm:items-center sm:gap-3 sm:px-5"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
<span className="mx-2 text-ink-200">|</span>
|
||||
预测 <span className={`font-serif font-bold ${r.correct_1x2 ? 'text-ink-900' : 'text-ink-400'}`}>
|
||||
{r.pred_home ?? '-'}:{r.pred_away ?? '-'}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex-shrink-0 sm:w-16 sm:text-right">
|
||||
{r.correct_1x2 ? <Badge status="success">命中</Badge> : <Badge status="loss">未中</Badge>}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
/**
|
||||
* Admin 后台 - 数据采集页面(bzzoiro 单一数据源)
|
||||
*
|
||||
* 三个采集任务:
|
||||
* events — 比赛日程与比分
|
||||
* standings — 联赛积分榜
|
||||
* stats — 已完赛比赛详细统计回填
|
||||
* all — 依次执行以上三项
|
||||
*
|
||||
* 响应式布局: 移动端单列,桌面端双列
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||
import { triggerCollection, fetchLeagues, fetchIngestStatus, fetchIngestJob } from '../dal'
|
||||
import type { IngestSourceStatus } from '../types'
|
||||
import type { CollectionRequest, League, IngestJob } 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: '⏵⏵' },
|
||||
] as const
|
||||
|
||||
type TaskStatus = 'idle' | 'running' | 'done' | 'error'
|
||||
|
||||
const JOB_STATUS_LABEL: Record<IngestJob['status'], string> = {
|
||||
pending: '等待中',
|
||||
running: '执行中',
|
||||
success: '已完成',
|
||||
failed: '失败',
|
||||
}
|
||||
|
||||
/** job.result 摘要 → 多行文本(错误只显示条数,明细见「采集失败」页与系统日志) */
|
||||
function summarizeResult(result: Record<string, any> | null): string[] {
|
||||
if (!result) return []
|
||||
return Object.entries(result).map(([k, v]) => {
|
||||
if (v && typeof v === 'object') {
|
||||
const bits: string[] = []
|
||||
for (const [kk, vv] of Object.entries(v)) {
|
||||
if (kk === 'errors') {
|
||||
if (Array.isArray(vv) && vv.length > 0) bits.push(`错误 ${vv.length} 条`)
|
||||
} else if (vv !== null && vv !== undefined) {
|
||||
bits.push(`${kk} ${vv}`)
|
||||
}
|
||||
}
|
||||
return `${k}: ${bits.length ? bits.join(' · ') : '无变更'}`
|
||||
}
|
||||
return `${k}: ${String(v)}`
|
||||
})
|
||||
}
|
||||
|
||||
export default function CollectionPage() {
|
||||
const [leagues, setLeagues] = useState<League[]>([])
|
||||
const [task, setTask] = useState<string>('events')
|
||||
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 jobPollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const [ingestSnap, setIngestSnap] = useState<IngestSourceStatus | null>(null)
|
||||
// 采集任务状态(ingest_jobs):提交后有 job_id 即轮询,替代「30 秒盲等」
|
||||
const [job, setJob] = useState<IngestJob | null>(null)
|
||||
|
||||
const loadLeagues = useCallback(async () => {
|
||||
const lg = await fetchLeagues()
|
||||
setLeagues(lg)
|
||||
}, [])
|
||||
|
||||
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 }
|
||||
}, [])
|
||||
|
||||
// job 轮询:3 秒一次,终态(success/failed)自动停止;10 分钟兜底防泄漏
|
||||
const stopJobPolling = useCallback(() => {
|
||||
if (jobPollRef.current) { clearInterval(jobPollRef.current); jobPollRef.current = null }
|
||||
}, [])
|
||||
|
||||
const startJobPolling = useCallback((jobId: string) => {
|
||||
stopJobPolling()
|
||||
let ticks = 0
|
||||
jobPollRef.current = setInterval(async () => {
|
||||
ticks += 1
|
||||
if (ticks > 200) { stopJobPolling(); setTaskStatus('done'); return }
|
||||
try {
|
||||
const j = await fetchIngestJob(jobId)
|
||||
setJob(j)
|
||||
if (j.status === 'success') { stopJobPolling(); setTaskStatus('done') }
|
||||
else if (j.status === 'failed') { stopJobPolling(); setTaskStatus('error') }
|
||||
} catch { /* 网络抖动忽略,下个周期重试 */ }
|
||||
}, 3_000)
|
||||
}, [stopJobPolling])
|
||||
|
||||
useEffect(() => () => { stopPolling(); stopJobPolling() }, [stopPolling, stopJobPolling])
|
||||
|
||||
const isEventsTask = task === 'events' || task === 'all'
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setResult(null)
|
||||
setJob(null)
|
||||
setLoading(true)
|
||||
setTaskStatus('running')
|
||||
setTaskStartedAt(Date.now())
|
||||
|
||||
try {
|
||||
const body: CollectionRequest = {
|
||||
source: 'bzzoiro',
|
||||
leagues: leagueCode ? [leagueCode] : undefined,
|
||||
task: task as CollectionRequest['task'],
|
||||
limit,
|
||||
season: season || undefined,
|
||||
status: ingestStatus || undefined,
|
||||
date_from: isEventsTask ? dateFrom || undefined : undefined,
|
||||
date_to: isEventsTask ? dateTo || undefined : undefined,
|
||||
}
|
||||
const resp = await triggerCollection(body)
|
||||
const jobId: string | undefined = resp?.job_id
|
||||
if (jobId) {
|
||||
// 有 job_id:轮询任务状态直至终态(3 秒/次)
|
||||
setResult({
|
||||
title: '采集任务已启动',
|
||||
detail: `任务 ID: ${jobId}。正在跟踪执行进度,完成后展示结果摘要。`,
|
||||
})
|
||||
startJobPolling(jobId)
|
||||
} else {
|
||||
// 兜底:后端未返回 job_id(旧版本),退回「30 秒盲等 + 系统日志」
|
||||
setResult({
|
||||
title: '采集任务已启动',
|
||||
detail: '正在后台执行(上游限速时可能需要数分钟)。完成结果与错误请到「系统日志」页查看(支持自动刷新)。',
|
||||
})
|
||||
startPolling()
|
||||
setTimeout(() => {
|
||||
setTaskStatus('done')
|
||||
stopPolling()
|
||||
}, 30_000)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
setTaskStatus('error')
|
||||
setError(err instanceof Error ? err.message : '采集触发失败')
|
||||
stopPolling()
|
||||
stopJobPolling()
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const elapsed = taskStartedAt ? Math.round((Date.now() - taskStartedAt) / 1000) : 0
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SectionHeader
|
||||
title="数据采集"
|
||||
description="bzzoiro 单一数据源:比赛数据、积分榜、比赛统计三条管线。采集为后台异步执行。"
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* 采集表单 */}
|
||||
<Card>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 联赛选择 */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">联赛</label>
|
||||
<select
|
||||
value={leagueCode}
|
||||
onChange={e => setLeagueCode(e.target.value)}
|
||||
className="field w-full"
|
||||
>
|
||||
<option value="">全部联赛</option>
|
||||
{leagues.map(l => (
|
||||
<option key={l.code} value={l.code}>{l.name_zh || l.name}</option>
|
||||
))}
|
||||
</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') && (
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">赛季(留空取当前赛季)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={season}
|
||||
onChange={e => setSeason(e.target.value)}
|
||||
placeholder="如 2026-2027"
|
||||
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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 消息提示 */}
|
||||
{error && <Alert kind="error" title="采集失败" message={error} onClose={() => setError(null)} />}
|
||||
{result && (
|
||||
<Alert
|
||||
kind="ok"
|
||||
title={result.title}
|
||||
message={result.detail || undefined}
|
||||
onClose={() => setResult(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 提交按钮 */}
|
||||
<button type="submit" disabled={loading} className="btn btn-solid w-full">
|
||||
{loading ? (<><Spinner /> 采集中</>) : '触发采集'}
|
||||
</button>
|
||||
</form>
|
||||
</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>
|
||||
{job
|
||||
? `${JOB_STATUS_LABEL[job.status]}(轮询中),已运行 ${elapsed}s…`
|
||||
: `任务执行中,已运行 ${elapsed}s…`}
|
||||
</span>
|
||||
</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>
|
||||
{job
|
||||
? `采集完成(${JOB_STATUS_LABEL[job.status]})`
|
||||
: '任务已提交,后台执行中(可能尚未完成)'}
|
||||
</span>
|
||||
</div>
|
||||
{job?.result && summarizeResult(job.result).length > 0 && (
|
||||
<div className="rounded-md bg-ink-50 px-3 py-2">
|
||||
{summarizeResult(job.result).map((line, i) => (
|
||||
<p key={i} className="font-mono text-2xs leading-relaxed text-ink-600">{line}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!job && (
|
||||
<p className="text-2xs text-ink-400">
|
||||
采集耗时取决于数据量。请到「系统日志」页查看最终结果。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{taskStatus === 'error' && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-press">
|
||||
{job?.status === 'failed' ? '采集任务失败' : '任务触发失败,请检查配置或网络。'}
|
||||
</p>
|
||||
{job?.error && (
|
||||
<p className="break-all rounded-md bg-red-50 px-3 py-2 font-mono text-2xs text-press">
|
||||
{job.error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{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>
|
||||
|
||||
{/* 数据源说明 */}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* Admin 后台 - 系统配置管理页面(报刊风)
|
||||
*
|
||||
* 功能:
|
||||
* - 登录与鉴权说明(ADMIN_PASSWORD,HttpOnly 会话 Cookie)
|
||||
* - 显示当前 .env 配置(脱敏;后端暂无配置端点,为静态说明)
|
||||
* - 配置修改指南
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { fetchSystemConfig } from '../dal'
|
||||
import { changePassword, fetchAuthState, UNAUTHORIZED_EVENT } from '../api'
|
||||
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components'
|
||||
|
||||
export default function ConfigPage() {
|
||||
const [config, setConfig] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [passwordOrigin, setPasswordOrigin] = useState<'db' | 'env' | 'none' | null>(null)
|
||||
|
||||
// 修改密码表单
|
||||
const [currentPwd, setCurrentPwd] = useState('')
|
||||
const [newPwd, setNewPwd] = useState('')
|
||||
const [confirmPwd, setConfirmPwd] = useState('')
|
||||
const [pwdBusy, setPwdBusy] = useState(false)
|
||||
const [pwdNotice, setPwdNotice] = useState<{ ok: boolean; text: string } | null>(null)
|
||||
|
||||
const loadConfig = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await fetchSystemConfig()
|
||||
setConfig(data)
|
||||
} catch {
|
||||
setConfig([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig()
|
||||
fetchAuthState()
|
||||
.then(s => setPasswordOrigin(s.password_origin ?? null))
|
||||
.catch(() => setPasswordOrigin(null))
|
||||
}, [loadConfig])
|
||||
|
||||
async function handleChangePassword(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-6">
|
||||
<SectionHeader
|
||||
title="系统配置"
|
||||
description="登录鉴权说明与系统参数查看。敏感配置一律通过服务器 .env 文件管理。"
|
||||
/>
|
||||
|
||||
{/* 登录与鉴权 */}
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="登录与鉴权"
|
||||
description="本后台通过密码登录保护,会话以 HttpOnly Cookie 保存,有效期默认 7 天"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
</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>
|
||||
|
||||
{/* 快速导航 */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<a
|
||||
href="/admin/data-sources"
|
||||
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">管理采集源 API Key</div>
|
||||
</div>
|
||||
<span className="text-ink-300 transition-colors group-hover:text-press" aria-hidden="true">→</span>
|
||||
</a>
|
||||
<a
|
||||
href="/admin/llm-config"
|
||||
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">LLM 配置</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>
|
||||
|
||||
{/* 配置列表 */}
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="当前配置"
|
||||
description="脱敏展示,实际值在服务器 .env 文件中"
|
||||
action={
|
||||
<button onClick={loadConfig} disabled={loading} className="btn btn-sm">
|
||||
{loading ? (<><Spinner /> 加载中</>) : '刷新'}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<CardBody className="px-0 sm:px-0">
|
||||
{loading ? (
|
||||
<div className="space-y-3 px-4 sm:px-5">
|
||||
{[1, 2, 3, 4, 5].map(i => (
|
||||
<SkeletonBlock key={i} className="h-9 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : config.length > 0 ? (
|
||||
<div>
|
||||
{config.map(item => (
|
||||
<div
|
||||
key={item.key}
|
||||
className="flex flex-col gap-1 border-b border-ink-200 px-4 py-2.5 last:border-b-0 sm:grid sm:grid-cols-[minmax(0,2fr)_minmax(0,3fr)_minmax(0,2fr)] sm:items-baseline sm:gap-4 sm:px-5"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-ink-800">{item.key}</span>
|
||||
{item.is_sensitive && <Badge status="warning">敏感</Badge>}
|
||||
</div>
|
||||
<div className="break-all font-mono text-2xs text-ink-500">{item.value_masked}</div>
|
||||
<div className="text-2xs text-ink-400">{item.description}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-8 text-center text-xs text-ink-400">无法加载配置信息</p>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 修改指南 */}
|
||||
<Card>
|
||||
<CardHeader title="修改配置指南" />
|
||||
<CardBody className="space-y-5">
|
||||
<div>
|
||||
<h4 className="mb-2 font-serif text-sm font-bold text-ink-900">通过 SSH 修改 .env</h4>
|
||||
<pre className="overflow-x-auto border border-ink-200 bg-paper-100 p-3 font-mono text-2xs leading-relaxed text-ink-700">
|
||||
{`# 连接到部署主机
|
||||
ssh user@your-server-ip
|
||||
|
||||
# 进入项目目录
|
||||
cd /vol2/1000/Docker/Profeto
|
||||
|
||||
# 编辑 .env 文件
|
||||
nano .env
|
||||
|
||||
# 修改后重启后端服务
|
||||
docker compose restart api
|
||||
|
||||
# 查看日志确认生效
|
||||
docker compose logs -f api`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-2 font-serif text-sm font-bold text-ink-900">常用配置项说明</h4>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
['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)'],
|
||||
['BZZOIRO_KEY', 'Bzzoiro 数据源 API 密钥'],
|
||||
['DATABASE_URL', 'PostgreSQL 数据库连接字符串'],
|
||||
].map(([key, desc]) => (
|
||||
<div key={key} className="flex items-start gap-2.5">
|
||||
<code className="flex-shrink-0 border border-ink-200 bg-paper-100 px-1.5 py-0.5 font-mono text-2xs text-ink-800">
|
||||
{key}
|
||||
</code>
|
||||
<span className="text-xs leading-relaxed text-ink-600">{desc}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Admin 后台 - 仪表盘(报刊风)
|
||||
*
|
||||
* 展示:
|
||||
* - 数据流水线状态(采集 → 预测 → 评估,每步的实际数据量)
|
||||
* - 近期预测活动(24h / 7d / 总计)
|
||||
* - 快捷操作入口(带工作流引导)
|
||||
*/
|
||||
|
||||
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: '◈' },
|
||||
]
|
||||
|
||||
export default function Dashboard() {
|
||||
const [stats, setStats] = useState<AdminStats | null>(null)
|
||||
const [ingest, setIngest] = useState<IngestSourceStatus[]>([])
|
||||
const [dash, setDash] = useState<DashboardStats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
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)
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const sourceByName = Object.fromEntries(ingest.map(s => [s.name, s]))
|
||||
const bzzoiro = sourceByName['bzzoiro']
|
||||
|
||||
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>
|
||||
|
||||
{/* ── 数据源健康一览 ── */}
|
||||
<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 的调用量统计" />
|
||||
<CardBody>
|
||||
{stats ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div className="font-serif text-3xl font-bold tabular-nums text-ink-900">{stats.predictions.last_24h}</div>
|
||||
<div className="mt-1 text-2xs text-ink-400">近 24 小时</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-serif text-3xl font-bold tabular-nums text-ink-900">{stats.predictions.last_7d}</div>
|
||||
<div className="mt-1 text-2xs text-ink-400">近 7 天</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-serif text-3xl font-bold tabular-nums text-ink-900">{stats.predictions.total}</div>
|
||||
<div className="mt-1 text-2xs text-ink-400">累计</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* F3 修复: 真实比赛计数(非 limit=100 近似) */}
|
||||
<div className="grid grid-cols-4 gap-3 border-t border-ink-200 pt-3 text-center">
|
||||
<div>
|
||||
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">{stats.matches?.total ?? 0}</div>
|
||||
<div className="mt-0.5 text-2xs text-ink-400">比赛总数</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">{stats.matches?.finished ?? 0}</div>
|
||||
<div className="mt-0.5 text-2xs text-ink-400">已完赛</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">{stats.stats?.total ?? 0}</div>
|
||||
<div className="mt-0.5 text-2xs text-ink-400">统计行</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">{stats.standings?.total ?? 0}</div>
|
||||
<div className="mt-0.5 text-2xs text-ink-400">积分榜</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center py-4"><SkeletonBlock className="h-12 w-64" /></div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* ── 已入库联赛 ── */}
|
||||
{dash && dash.leagues.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader title="已入库联赛" />
|
||||
<CardBody>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{dash.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>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Admin 后台 - 数据完整性分析页
|
||||
*
|
||||
* 回答三个问题:
|
||||
* 1. 数据是否齐全(各联赛比赛/统计/积分榜量级)
|
||||
* 2. 字段是否齐全(每张统计表各字段非空率)
|
||||
* 3. 覆盖是否新鲜(最近一场/最近一次采集)
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||
import { fetchDataCompleteness } from '../dal'
|
||||
import type { DataCompletenessResponse } from '../dal'
|
||||
import {
|
||||
Card, CardBody, CardHeader, SectionHeader, Alert,
|
||||
ProgressBar, Spinner, EmptyState,
|
||||
} from '../components'
|
||||
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
xg: 'xG 预期进球',
|
||||
shots: '射门',
|
||||
possession: '控球率',
|
||||
corners: '角球',
|
||||
fouls: '犯规',
|
||||
big_chances: '绝佳机会',
|
||||
cards: '红黄牌',
|
||||
}
|
||||
|
||||
function pctColor(pct: number): string {
|
||||
if (pct >= 80) return 'bg-emerald-500'
|
||||
if (pct >= 50) return 'bg-amber-500'
|
||||
return 'bg-rose-500'
|
||||
}
|
||||
|
||||
/** 根据问题描述生成可操作的修复链接 */
|
||||
function getIssueAction(issue: string): { href: string; label: string; code: string } | null {
|
||||
// 提取联赛代码(大写字母+数字,如 E0, SP1)
|
||||
const codeMatch = issue.match(/\b([A-Z]{1,2}\d?)\b/)
|
||||
const code = codeMatch ? codeMatch[1] : ''
|
||||
if (!code) return null
|
||||
|
||||
if (issue.includes('无已完赛比赛')) {
|
||||
return { href: `/admin/collection?task=events&league=${code}`, label: '去采集', code }
|
||||
}
|
||||
if (issue.includes('无统计回填') || issue.includes('统计覆盖率')) {
|
||||
return { href: `/admin/collection?task=stats&league=${code}`, label: '去回填', code }
|
||||
}
|
||||
if (issue.includes('无积分榜')) {
|
||||
return { href: `/admin/collection?task=standings&league=${code}`, label: '去采集', code }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export default function DataCompletenessPage() {
|
||||
const [data, setData] = useState<DataCompletenessResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [highlightedLeague, setHighlightedLeague] = useState<string | null>(null)
|
||||
const leagueRefs = useRef<Record<string, HTMLDivElement | null>>({})
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const d = await fetchDataCompleteness()
|
||||
setData(d)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
// 点击问题项 → 滚动到对应联赛卡片并高亮
|
||||
const scrollToLeague = useCallback((code: string) => {
|
||||
setHighlightedLeague(code)
|
||||
// 使用 requestAnimationFrame 确保 DOM 已更新
|
||||
requestAnimationFrame(() => {
|
||||
const el = leagueRefs.current[code]
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
})
|
||||
// 5秒后移除高亮
|
||||
setTimeout(() => setHighlightedLeague(null), 5000)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SectionHeader
|
||||
title="数据完整性"
|
||||
description="按联赛统计 bzzoiro 数据采集覆盖度。每 5 秒自动刷新,或点击右上角按钮手动刷新。"
|
||||
action={
|
||||
<button onClick={load} disabled={loading} className="btn-sm btn-outline">
|
||||
{loading ? <><Spinner /> 刷新中</> : '刷新'}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{error && <Alert kind="error" title="加载失败" message={error} onClose={() => setError(null)} />}
|
||||
|
||||
{loading && !data && (
|
||||
<div className="flex justify-center py-12"><Spinner /></div>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
{/* 全局概览 */}
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Card>
|
||||
<CardBody className="text-center">
|
||||
<p className="text-2xl font-bold text-ink-900">{data.totals.finished_matches}</p>
|
||||
<p className="text-xs text-ink-500">已完赛比赛(总计)</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardBody className="text-center">
|
||||
<p className="text-2xl font-bold text-ink-900">{data.totals.stats_rows}</p>
|
||||
<p className="text-xs text-ink-500">统计行数</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardBody className="text-center">
|
||||
<p className="text-2xl font-bold text-ink-900">
|
||||
{data.totals.stats_coverage_pct}%
|
||||
</p>
|
||||
<p className="text-xs text-ink-500">统计覆盖率(有统计 / 已完赛)</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 健康问题(可操作) */}
|
||||
<Card>
|
||||
<CardHeader title="健康摘要" />
|
||||
<CardBody>
|
||||
<div className="space-y-2">
|
||||
{data.issues.map((issue, i) => {
|
||||
const action = getIssueAction(issue)
|
||||
return (
|
||||
<Alert
|
||||
key={i}
|
||||
kind={issue.includes('良好') ? 'ok' : issue.includes('建议') || issue.includes('仅') ? 'warning' : 'error'}
|
||||
title={issue.includes('良好') ? '数据良好' : '需要关注'}
|
||||
message={issue}
|
||||
action={action ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => scrollToLeague(action.code)}
|
||||
className="btn btn-sm whitespace-nowrap"
|
||||
>
|
||||
定位
|
||||
</button>
|
||||
<a href={action.href} className="btn btn-sm whitespace-nowrap">
|
||||
{action.label}
|
||||
</a>
|
||||
</div>
|
||||
) : undefined}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 各联赛详情 */}
|
||||
<div className="space-y-4">
|
||||
{data.leagues.map(league => {
|
||||
const finished = league.matches.finished
|
||||
const statsPct = finished > 0 ? Math.round((league.stats.rows / finished) * 100) : 0
|
||||
const isHighlighted = highlightedLeague === league.code
|
||||
return (
|
||||
<div
|
||||
key={league.code}
|
||||
ref={el => { leagueRefs.current[league.code] = el }}
|
||||
className={`transition-all duration-500 ${isHighlighted ? 'ring-2 ring-press bg-press-wash/40 scale-[1.01]' : ''}`}
|
||||
>
|
||||
<Card>
|
||||
<CardHeader
|
||||
title={league.name}
|
||||
description={[
|
||||
league.country,
|
||||
`已完赛 ${finished} 场 / 未开赛 ${league.matches.scheduled} 场`,
|
||||
league.matches.latest_match ? `最近: ${league.matches.latest_match.slice(0, 10)}` : '',
|
||||
league.standings.rows > 0 ? `积分榜 ${league.standings.rows} 队` : '',
|
||||
].filter(Boolean).join(' · ')}
|
||||
/>
|
||||
<CardBody className="space-y-4">
|
||||
{/* 统计覆盖率进度条 */}
|
||||
<div>
|
||||
<div className="mb-1 flex items-center justify-between text-xs">
|
||||
<span className="text-ink-600">统计回填覆盖率</span>
|
||||
<span className="font-medium text-ink-900">
|
||||
{league.stats.rows} / {finished} ({statsPct}%)
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar value={statsPct} />
|
||||
</div>
|
||||
|
||||
{/* 字段覆盖率矩阵 */}
|
||||
{league.stats.rows > 0 ? (
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-medium text-ink-600">字段覆盖率(有值行数 / 总行数)</p>
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{Object.entries(league.stats.fields).map(([key, info]) => (
|
||||
<div key={key} className="rounded border border-ink-100 px-3 py-2">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<span className="text-xs text-ink-600">{FIELD_LABELS[key] ?? key}</span>
|
||||
<span className="text-xs font-medium text-ink-900">{info.pct}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-ink-100">
|
||||
<div
|
||||
className={`h-full rounded-full ${pctColor(info.pct)}`}
|
||||
style={{ width: `${info.pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-0.5 text-2xs text-ink-400">{info.count} / {league.stats.rows} 行</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : finished > 0 ? (
|
||||
<Alert kind="warning" title="缺少统计数据" message="该联赛有已完赛比赛但无统计行,请运行「统计回填」采集。" />
|
||||
) : (
|
||||
<Alert kind="warning" title="缺少比赛数据" message="该联赛暂无已完赛比赛,请运行「比赛数据」采集。" />
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="text-center text-2xs text-ink-400">
|
||||
生成时间: {new Date(data.generated_at).toLocaleString('zh-CN', { hour12: false })}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Admin 后台 - 数据管线管理页(报刊风)
|
||||
*
|
||||
* 功能:
|
||||
* - 采集失败记录列表(可重试)
|
||||
* - 数据质量检查结果
|
||||
* - 手动触发质量检查
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import {
|
||||
fetchDataQuality,
|
||||
runDataQualityCheck,
|
||||
fetchIngestFailures,
|
||||
retryIngestFailure,
|
||||
} from '../dal'
|
||||
import type { IngestFailureItem, DataQualityCheckItem } from '../dal'
|
||||
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
|
||||
|
||||
export default function DataPipelinePage() {
|
||||
const [quality, setQuality] = useState<{ failures: IngestFailureItem[]; checks: DataQualityCheckItem[] } | null>(null)
|
||||
const [failures, setFailures] = useState<IngestFailureItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [running, setRunning] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [notice, setNotice] = useState<{ ok: boolean; text: string } | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [q, f] = await Promise.all([fetchDataQuality(), fetchIngestFailures()])
|
||||
setQuality(q)
|
||||
setFailures(f)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const handleRunCheck = async () => {
|
||||
setRunning(true)
|
||||
setNotice(null)
|
||||
try {
|
||||
const res = await runDataQualityCheck()
|
||||
const failed = res.checks.filter(c => !c.passed)
|
||||
setNotice({
|
||||
ok: failed.length === 0,
|
||||
text: failed.length === 0
|
||||
? '数据质量检查通过'
|
||||
: `检查完成: ${failed.length} 项未通过`,
|
||||
})
|
||||
await load()
|
||||
} catch {
|
||||
setNotice({ ok: false, text: '质量检查执行失败' })
|
||||
} finally {
|
||||
setRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRetry = async (id: number) => {
|
||||
setNotice(null)
|
||||
try {
|
||||
const res = await retryIngestFailure(id)
|
||||
setNotice({ ok: true, text: res.message })
|
||||
await load()
|
||||
} catch {
|
||||
setNotice({ ok: false, text: '重试操作失败' })
|
||||
}
|
||||
}
|
||||
|
||||
const pendingFailures = failures.filter(f => f.status === 'pending' || f.status === 'retrying')
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SectionHeader
|
||||
title="数据管线"
|
||||
description="采集失败重试、数据质量检查与监控"
|
||||
action={
|
||||
<button onClick={handleRunCheck} disabled={running} className="btn btn-sm">
|
||||
{running ? <><Spinner /> 检查中</> : '运行质量检查'}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{notice && (
|
||||
<Alert kind={notice.ok ? 'ok' : 'error'} title={notice.text} onClose={() => setNotice(null)} />
|
||||
)}
|
||||
|
||||
{error && <Alert kind="error" title="加载失败" message={error} onClose={() => setError(null)} />}
|
||||
|
||||
{loading && (
|
||||
<div className="flex justify-center py-12"><Spinner /></div>
|
||||
)}
|
||||
|
||||
{!loading && (
|
||||
<>
|
||||
{/* 采集失败记录 */}
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="采集失败记录"
|
||||
description={pendingFailures.length > 0 ? `${pendingFailures.length} 条待处理` : '暂无待处理失败记录'}
|
||||
/>
|
||||
<CardBody className="px-0 sm:px-0">
|
||||
{failures.length === 0 ? (
|
||||
<p className="py-8 text-center text-xs text-ink-400">暂无采集失败记录</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[640px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-ink-200 text-left text-ink-500">
|
||||
<th className="px-4 py-2 font-medium">来源</th>
|
||||
<th className="px-4 py-2 font-medium">实体类型</th>
|
||||
<th className="px-4 py-2 font-medium">错误类型</th>
|
||||
<th className="px-4 py-2 font-medium">重试次数</th>
|
||||
<th className="px-4 py-2 font-medium">状态</th>
|
||||
<th className="px-4 py-2 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{failures.map(f => (
|
||||
<tr key={f.id} className="border-b border-ink-100 hover:bg-paper-100">
|
||||
<td className="px-4 py-2 text-xs text-ink-700">{f.source}</td>
|
||||
<td className="px-4 py-2 text-xs text-ink-600">{f.entity_type}</td>
|
||||
<td className="px-4 py-2 text-xs text-ink-600">{f.error_type}</td>
|
||||
<td className="px-4 py-2 text-xs tabular-nums text-ink-500">{f.retry_count}</td>
|
||||
<td className="px-4 py-2">
|
||||
<Badge status={f.status === 'resolved' ? 'success' : f.status === 'pending' ? 'warning' : 'info'}>
|
||||
{f.status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
{(f.status === 'pending' || f.status === 'retrying') && (
|
||||
<button onClick={() => handleRetry(f.id)} className="btn btn-sm">
|
||||
重试
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 数据质量检查 */}
|
||||
<Card>
|
||||
<CardHeader title="数据质量检查" description="最近 20 条检查结果" />
|
||||
<CardBody className="px-0 sm:px-0">
|
||||
{quality?.checks.length === 0 ? (
|
||||
<p className="py-8 text-center text-xs text-ink-400">暂无质量检查记录,点击右上角「运行质量检查」触发</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[560px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-ink-200 text-left text-ink-500">
|
||||
<th className="px-4 py-2 font-medium">检查项</th>
|
||||
<th className="px-4 py-2 font-medium">实体</th>
|
||||
<th className="px-4 py-2 font-medium">结果</th>
|
||||
<th className="px-4 py-2 font-medium">严重度</th>
|
||||
<th className="px-4 py-2 font-medium">时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{quality?.checks.map(c => (
|
||||
<tr key={c.id} className="border-b border-ink-100 hover:bg-paper-100">
|
||||
<td className="px-4 py-2 text-xs text-ink-700">{c.check_name}</td>
|
||||
<td className="px-4 py-2 text-xs text-ink-600">{c.entity_type}</td>
|
||||
<td className="px-4 py-2">
|
||||
<Badge status={c.passed ? 'success' : 'error'}>
|
||||
{c.passed ? '通过' : '未通过'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<Badge status={c.severity === 'warning' ? 'warning' : c.severity === 'error' ? 'error' : 'info'}>
|
||||
{c.severity}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-2xs text-ink-400">
|
||||
{c.checked_at ? new Date(c.checked_at).toLocaleString('zh-CN', { hour12: false }) : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
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'
|
||||
|
||||
function formatTime(iso: string | null): string {
|
||||
if (!iso) return '暂无记录'
|
||||
try {
|
||||
return new Date(iso).toLocaleString('zh-CN', { hour12: false })
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
|
||||
export default function DataSourcesPage() {
|
||||
const [sources, setSources] = useState<DataSourceStatus[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadError, setLoadError] = useState('')
|
||||
const [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 loadSources = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setLoadError('')
|
||||
try {
|
||||
setSources(await fetchDataSourceStatuses())
|
||||
} catch (err) {
|
||||
setLoadError(err instanceof Error ? err.message.split('\n')[0] : '加载失败')
|
||||
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])
|
||||
|
||||
async function handleTest(sourceName: string) {
|
||||
setTestingSource(sourceName)
|
||||
setTestResults(prev => ({ ...prev, [sourceName]: { ok: false, status: null, latency_ms: 0, detail: '测试中...' } }))
|
||||
try {
|
||||
const result = await testDataSourceConnection(sourceName)
|
||||
setTestResults(prev => ({ ...prev, [sourceName]: result }))
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message.split('\n')[0] : '连接失败'
|
||||
setTestResults(prev => ({ ...prev, [sourceName]: { ok: false, status: null, latency_ms: 0, detail: msg } }))
|
||||
} finally {
|
||||
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 配置、健康状态与连通性测试。"
|
||||
/>
|
||||
|
||||
{loadError && (
|
||||
<Alert kind="error" title="无法加载数据源配置" message={loadError} />
|
||||
)}
|
||||
|
||||
{/* 数据源卡片 */}
|
||||
{loading ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{[1, 2, 3].map(i => (
|
||||
<Card key={i}>
|
||||
<CardBody>
|
||||
<div className="space-y-3">
|
||||
<SkeletonBlock className="h-4 w-24" />
|
||||
<SkeletonBlock className="h-3 w-32" />
|
||||
<SkeletonBlock className="h-8 w-full" />
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<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">
|
||||
<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>
|
||||
</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)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-2xs text-ink-400">无需 API Key</p>
|
||||
)}
|
||||
|
||||
{rowNotice && cardKeys.includes(rowNotice.key) && (
|
||||
<Alert kind={rowNotice.ok ? 'ok' : 'error'} title={rowNotice.text} />
|
||||
)}
|
||||
|
||||
{/* 数据源健康:最近采集 + 异常提示 */}
|
||||
{renderHealth(source.name)}
|
||||
|
||||
{result && testingSource !== source.name && (
|
||||
<Alert
|
||||
kind={result.ok ? 'ok' : 'error'}
|
||||
title={result.ok ? `连接成功(${result.latency_ms}ms)` : result.status ? `HTTP ${result.status}` : '连接失败'}
|
||||
message={result.ok ? undefined : result.detail}
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => handleTest(source.name)}
|
||||
disabled={testingSource === source.name}
|
||||
className="btn btn-sm w-full"
|
||||
>
|
||||
{testingSource === source.name ? (<><Spinner /> 测试中</>) : '测试连接'}
|
||||
</button>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Key 轮换环状态 */}
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="API Key 轮换环"
|
||||
description={keyRing?.has_multiple
|
||||
? `已配置 ${keyRing.total} 个 key,遇到限流(429)自动切换;冷却 ${keyRing.cooldown_seconds}s`
|
||||
: '当前仅 1 个 key,无法轮换。建议配置多个 key 以提高限流容忍度'
|
||||
}
|
||||
action={
|
||||
<button
|
||||
onClick={handleResetCooldown}
|
||||
disabled={ringLoading}
|
||||
className="btn-sm btn-outline"
|
||||
>
|
||||
重置冷却
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<CardBody>
|
||||
{ringLoading && !keyRing ? (
|
||||
<SkeletonBlock className="h-10 w-full" />
|
||||
) : keyRing && keyRing.total > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{keyRing.keys.map((k, i) => {
|
||||
const isBlocked = k.blocked_remaining > 0
|
||||
return (
|
||||
<div key={i} className={`flex items-center justify-between gap-3 border-b border-ink-100 py-2 last:border-b-0 ${isBlocked ? 'opacity-70' : ''}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-block h-2 w-2 rounded-full ${isBlocked ? 'bg-amber-500' : 'bg-emerald-500'}`} />
|
||||
<span className="font-mono text-xs text-ink-700">{k.masked}</span>
|
||||
{i === keyRing.active_index && (
|
||||
<span className="rounded bg-ink-900 px-1.5 py-0.5 text-2xs text-paper-500">当前</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={`text-2xs tabular-nums ${isBlocked ? 'text-amber-600' : 'text-ink-400'}`}>
|
||||
{isBlocked ? `冷却中 ${k.blocked_remaining.toFixed(0)}s` : '可用'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-ink-400">暂无 key 配置</p>
|
||||
)}
|
||||
<p className="mt-3 border-l-2 border-ink-300 pl-3 text-2xs leading-relaxed text-ink-500">
|
||||
在「Bzzoiro」配置项中用<b>逗号 / 分号 / 换行</b>分隔多个 key 即可启用轮换。遇到 429 自动标记当前 key 为冷却并立即切换到下一个 key;
|
||||
全部 key 冷却时等待最早恢复的 key。「重置冷却」可紧急恢复所有 key。
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 近期活动统计(只读) */}
|
||||
{stats && stats.predictions && (
|
||||
<Card>
|
||||
<CardHeader title="近期预测活动" description="过去 24 小时 / 7 天的预测次数" />
|
||||
<CardBody>
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">{stats.predictions.last_24h}</div>
|
||||
<div className="mt-1 text-2xs text-ink-400">近 24 小时</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">{stats.predictions.last_7d}</div>
|
||||
<div className="mt-1 text-2xs text-ink-400">近 7 天</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">{stats.predictions.total}</div>
|
||||
<div className="mt-1 text-2xs text-ink-400">总计</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader title="配置说明" />
|
||||
<CardBody>
|
||||
<div className="space-y-3 text-xs leading-relaxed text-ink-600">
|
||||
<p className="border-l-2 border-ink-300 pl-3">
|
||||
保存的配置存于数据库 <code className="font-mono">app_settings</code> 并<b>立即生效</b>,优先于 <code className="font-mono">.env</code>;「回落 .env」删除覆盖值。
|
||||
</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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* Admin 后台 - LLM 配置管理页面(报刊风)
|
||||
*
|
||||
* 功能:
|
||||
* - 显示当前 LLM 配置(provider, model, base_url;后端暂无配置端点,当前值取自 .env 约定)
|
||||
* - 测试 LLM 连接(会真实调用一次 /predict,产生 LLM 调用费用)
|
||||
* - 显示 LLM 使用统计(从预测记录聚合)
|
||||
* - 可用模型列表
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { testLLMConnection, fetchLLMUsageStats, fetchSettings, fetchLLMModels, updateSetting, clearSetting } from '../dal'
|
||||
import type { LLMUsageStats } from '../types'
|
||||
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components'
|
||||
import SettingRow from '../SettingRow'
|
||||
import AgentLLMCard from '../AgentLLMCard'
|
||||
import type { DataSourceSetting } from '../types'
|
||||
|
||||
const LLM_SETTING_KEYS = ['LLM_API_KEY', 'LLM_BASE_URL', 'LLM_MODEL']
|
||||
|
||||
export default function LLMConfigPage() {
|
||||
const [stats, setStats] = useState<LLMUsageStats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
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)
|
||||
|
||||
const loadStats = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await fetchLLMUsageStats()
|
||||
setStats(data)
|
||||
} catch {
|
||||
setStats(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadSettings = useCallback(async () => {
|
||||
setSettingsLoading(true)
|
||||
try {
|
||||
const all = await fetchSettings()
|
||||
setLlmSettings(all.filter(x => LLM_SETTING_KEYS.includes(x.key)))
|
||||
} catch {
|
||||
setLlmSettings([])
|
||||
} finally {
|
||||
setSettingsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadStats()
|
||||
loadSettings()
|
||||
}, [loadStats, loadSettings])
|
||||
|
||||
/** 供 LLM_MODEL 行内检测:探测当前服务可用模型,失败抛错由行内展示 */
|
||||
const detectLLMModels = useCallback(async (): Promise<string[]> => {
|
||||
const r = await fetchLLMModels()
|
||||
if (!r.ok) throw new Error(r.detail)
|
||||
return r.models
|
||||
}, [])
|
||||
|
||||
async function handleSave(key: string, value: string) {
|
||||
setBusyKey(key)
|
||||
setRowNotice(null)
|
||||
try {
|
||||
await updateSetting(key, value)
|
||||
setRowNotice({ key, ok: true, text: '已保存,立即生效' })
|
||||
setEditingKey(null)
|
||||
await loadSettings()
|
||||
} catch (err) {
|
||||
setRowNotice({ key, ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '保存失败' })
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClear(key: string) {
|
||||
setBusyKey(key)
|
||||
setRowNotice(null)
|
||||
try {
|
||||
await clearSetting(key)
|
||||
setRowNotice({ key, ok: true, text: '已清除数据库覆盖,回落 .env 默认值' })
|
||||
await loadSettings()
|
||||
} catch (err) {
|
||||
setRowNotice({ key, ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '清除失败' })
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTest() {
|
||||
setTesting(true)
|
||||
setTestResult(null)
|
||||
try {
|
||||
await testLLMConnection()
|
||||
setTestResult({ success: true, message: 'LLM 连接测试成功' })
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'LLM 连接测试失败'
|
||||
setTestResult({ success: false, message: msg })
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SectionHeader
|
||||
title="LLM 配置"
|
||||
description="大语言模型连接状态与使用统计。模型切换通过修改 .env 并重启服务完成。"
|
||||
/>
|
||||
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
|
||||
{/* 测试连接 */}
|
||||
{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={loadStats} disabled={loading} className="btn btn-sm">
|
||||
{loading ? (<><Spinner /> 加载中</>) : '刷新'}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<CardBody>
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
<SkeletonBlock className="h-16 w-full" />
|
||||
<SkeletonBlock className="h-16 w-full" />
|
||||
</div>
|
||||
) : stats ? (
|
||||
<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">
|
||||
{stats.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">
|
||||
{stats.avg_latency_ms > 0 ? `${(stats.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">
|
||||
{stats.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>
|
||||
|
||||
{/* 专家与终裁独立配置 */}
|
||||
<AgentLLMCard />
|
||||
|
||||
{/* 最近预测 */}
|
||||
<Card>
|
||||
<CardHeader title="最近预测记录" />
|
||||
<CardBody className="px-0 sm:px-0">
|
||||
{loading ? (
|
||||
<div className="space-y-2 px-4 sm:px-5">
|
||||
{[1, 2, 3].map(i => (
|
||||
<SkeletonBlock key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : stats && stats.recent_predictions.length > 0 ? (
|
||||
<div>
|
||||
{stats.recent_predictions.map(p => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="flex flex-col gap-1.5 border-b border-ink-200 px-4 py-2.5 last:border-b-0 sm:flex-row sm:items-center sm:justify-between sm:px-5"
|
||||
>
|
||||
<div className="flex items-baseline gap-3">
|
||||
<span className="text-2xs tabular-nums text-ink-400">#{p.id}</span>
|
||||
<span className="text-xs text-ink-800">比赛 #{p.match_id}</span>
|
||||
<span className="font-mono text-2xs text-ink-500">{p.model}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xs tabular-nums text-ink-400">
|
||||
{p.created_at ? new Date(p.created_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '—'}
|
||||
</span>
|
||||
{p.status === 'success' ? <Badge status="success">成功</Badge> : <Badge status="error">失败</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-8 text-center text-xs text-ink-400">暂无预测记录</p>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Admin 后台 - 监控面板(报刊风)
|
||||
*
|
||||
* 功能:
|
||||
* - /health 存活检查(自动:30 秒一轮;可手动刷新)
|
||||
* - /health/ready 数据库就绪检查
|
||||
* - 服务名 / 版本 / 运行时间 / 检查项(后端返回什么就展示什么)
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { fetchHealth } from '../dal'
|
||||
import { api } from '../api'
|
||||
import { Card, CardBody, CardHeader, SectionHeader, Alert, Spinner, Badge } from '../components'
|
||||
|
||||
export default function MonitoringPage() {
|
||||
const [health, setHealth] = useState<any>(null)
|
||||
const [ready, setReady] = useState<'ready' | 'not_ready' | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [lastCheck, setLastCheck] = useState<string>('')
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [h, r] = await Promise.allSettled([
|
||||
fetchHealth(),
|
||||
api.get<{ status: string }>('/health/ready'),
|
||||
])
|
||||
setHealth(h.status === 'fulfilled' ? h.value : null)
|
||||
setReady(r.status === 'fulfilled' ? (r.value?.status as 'ready' | 'not_ready') : null)
|
||||
if (h.status === 'rejected') {
|
||||
setError(h.reason instanceof Error ? h.reason.message : '无法连接到后端')
|
||||
}
|
||||
setLastCheck(new Date().toLocaleTimeString('zh-CN', { hour12: false }))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
refresh()
|
||||
const t = setInterval(refresh, 30_000)
|
||||
return () => clearInterval(t)
|
||||
}, [refresh])
|
||||
|
||||
const alive = health?.status === 'healthy' || health?.status === 'ok'
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SectionHeader
|
||||
title="系统监控"
|
||||
description="存活与数据库就绪检查,每 30 秒自动巡检一次。"
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-2xs text-ink-400">
|
||||
{lastCheck && `最近巡检 ${lastCheck}`}
|
||||
</span>
|
||||
<button onClick={refresh} disabled={loading} className="btn btn-sm">
|
||||
{loading ? (<><Spinner /> 检查中</>) : '立即巡检'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert
|
||||
kind="error"
|
||||
title="无法连接到后端"
|
||||
message={`${error}\n请确认服务是否正常运行,以及登录会话是否已过期。`}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{/* 存活状态 */}
|
||||
<div className="border border-ink-900 bg-paper-50 p-5">
|
||||
<div className="text-2xs tracking-[0.2em] text-ink-400">存活状态</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span
|
||||
className={`inline-block h-2 w-2 ${alive ? 'bg-ink-900' : 'bg-press'}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className={`font-serif text-xl font-bold ${alive ? 'text-ink-900' : 'text-press'}`}>
|
||||
{health ? (alive ? '正常' : String(health.status)) : '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 数据库就绪 */}
|
||||
<div className="border border-ink-900 bg-paper-50 p-5">
|
||||
<div className="text-2xs tracking-[0.2em] text-ink-400">数据库就绪</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span
|
||||
className={`inline-block h-2 w-2 ${ready === 'ready' ? 'bg-ink-900' : ready === null ? 'bg-ink-300' : 'bg-press'}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
className={`font-serif text-xl font-bold ${ready === 'not_ready' ? 'text-press' : 'text-ink-900'}`}
|
||||
>
|
||||
{ready === 'ready' ? '就绪' : ready === 'not_ready' ? '未就绪' : '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 服务名 */}
|
||||
<div className="border border-ink-900 bg-paper-50 p-5">
|
||||
<div className="text-2xs tracking-[0.2em] text-ink-400">服务</div>
|
||||
<div className="mt-2 font-serif text-xl font-bold text-ink-900">
|
||||
{health?.service || 'profeto'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 版本 */}
|
||||
{health?.version && (
|
||||
<div className="border border-ink-900 bg-paper-50 p-5">
|
||||
<div className="text-2xs tracking-[0.2em] text-ink-400">版本</div>
|
||||
<div className="mt-2 font-serif text-xl font-bold tabular-nums text-ink-900">
|
||||
{health.version}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 运行时间 */}
|
||||
{health?.uptime_seconds != null && (
|
||||
<div className="border border-ink-900 bg-paper-50 p-5">
|
||||
<div className="text-2xs tracking-[0.2em] text-ink-400">运行时间</div>
|
||||
<div className="mt-2 font-serif text-xl font-bold tabular-nums text-ink-900">
|
||||
{Math.floor(health.uptime_seconds / 3600)}h{' '}
|
||||
{Math.floor((health.uptime_seconds % 3600) / 60)}m
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 检查项 */}
|
||||
{health?.checks && Object.keys(health.checks).length > 0 && (
|
||||
<div className="border border-ink-900 bg-paper-50 p-5 sm:col-span-2 lg:col-span-1">
|
||||
<div className="text-2xs tracking-[0.2em] text-ink-400">健康检查</div>
|
||||
<div className="mt-2 space-y-1">
|
||||
{Object.entries(health.checks).map(([key, val]) => (
|
||||
<div key={key} className="flex items-center justify-between text-sm">
|
||||
<span className="text-2xs text-ink-500">{key}</span>
|
||||
<Badge status={String(val) === 'pass' ? 'success' : 'error'}>
|
||||
{String(val)}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* Admin 后台 - 预测历史页面(报刊风)
|
||||
*
|
||||
* 功能:
|
||||
* - 查看预测记录列表(支持状态筛选)
|
||||
* - 实际比分自动从比赛赛果填充
|
||||
* - 赛后一键结算,供准确率评估
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { fetchPredictions, settlePrediction } from '../dal'
|
||||
import type { Prediction } from '../types'
|
||||
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
|
||||
import TeamSideTag from '../../components/TeamSideTag'
|
||||
|
||||
const OUTCOME_LABEL: Record<string, string> = { '1': '主胜', X: '平局', '2': '客胜' }
|
||||
|
||||
function fmtDate(s?: string | null): string {
|
||||
if (!s) return '—'
|
||||
const d = new Date(s)
|
||||
return isNaN(d.getTime()) ? s : `${d.getMonth() + 1}/${d.getDate()}`
|
||||
}
|
||||
|
||||
export default function PredictionHistoryPage() {
|
||||
const [predictions, setPredictions] = useState<Prediction[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [filter, setFilter] = useState<'all' | 'settled' | 'unsettled'>('all')
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null)
|
||||
const [settlingId, setSettlingId] = useState<number | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const list = await fetchPredictions(200)
|
||||
setPredictions(list)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const handleSettle = async (p: Prediction) => {
|
||||
const match = (p as any).match
|
||||
if (!match || match.home_goals == null || match.away_goals == null) return
|
||||
setSettlingId(p.id)
|
||||
try {
|
||||
await settlePrediction(p.id, match.home_goals, match.away_goals)
|
||||
await load()
|
||||
} catch (err) {
|
||||
console.error('结算失败:', err)
|
||||
} finally {
|
||||
setSettlingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = predictions.filter(p => {
|
||||
if (filter === 'settled') return p.settled
|
||||
if (filter === 'unsettled') return !p.settled
|
||||
return true
|
||||
})
|
||||
|
||||
const unsettledCount = predictions.filter(p => !p.settled).length
|
||||
const settledWithScore = predictions.filter(p => p.settled && p.actual_home_goals != null && p.actual_away_goals != null)
|
||||
const hitCount = settledWithScore.filter(p => {
|
||||
const actual = p.actual_home_goals! > p.actual_away_goals! ? '1' : p.actual_home_goals! < p.actual_away_goals! ? '2' : 'X'
|
||||
return actual === p.pred_1x2
|
||||
}).length
|
||||
const accuracy = settledWithScore.length > 0 ? Math.round(hitCount / settledWithScore.length * 100) : 0
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SectionHeader
|
||||
title="预测历史"
|
||||
description={`共 ${predictions.length} 条记录${unsettledCount > 0 ? `, ${unsettledCount} 条待结算` : ''}`}
|
||||
/>
|
||||
|
||||
{/* 统计概览 */}
|
||||
{settledWithScore.length > 0 && (
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Card>
|
||||
<CardBody className="text-center">
|
||||
<p className="text-2xl font-bold text-ink-900">{settledWithScore.length}</p>
|
||||
<p className="text-xs text-ink-500">已结算</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardBody className="text-center">
|
||||
<p className="text-2xl font-bold text-emerald-700">{hitCount}</p>
|
||||
<p className="text-xs text-ink-500">命中</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardBody className="text-center">
|
||||
<p className="text-2xl font-bold text-press">{accuracy}%</p>
|
||||
<p className="text-xs text-ink-500">1X2 准确率</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 筛选 */}
|
||||
<div className="flex gap-2">
|
||||
{([
|
||||
{ v: 'all', label: '全部' },
|
||||
{ v: 'unsettled', label: '待结算' },
|
||||
{ v: 'settled', label: '已结算' },
|
||||
] as const).map(opt => (
|
||||
<button
|
||||
key={opt.v}
|
||||
onClick={() => setFilter(opt.v)}
|
||||
className={`btn btn-sm ${filter === opt.v ? 'btn-solid' : ''}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="flex justify-center py-12"><Spinner /></div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert kind="error" title="加载失败" message={error} onClose={() => setError(null)} />
|
||||
)}
|
||||
|
||||
{!loading && !error && filtered.length === 0 && (
|
||||
<div className="empty-state">
|
||||
<p className="empty-state-title">暂无预测记录</p>
|
||||
<p className="empty-state-sub">在主站比赛列表点击「预测」按钮生成预测记录</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 预测列表 */}
|
||||
{!loading && !error && filtered.length > 0 && (
|
||||
<Card>
|
||||
<CardBody className="px-0 sm:px-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[700px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-ink-200 text-left text-ink-500">
|
||||
<th className="px-4 py-2 font-medium">比赛日</th>
|
||||
<th className="px-4 py-2 font-medium">比赛</th>
|
||||
<th className="px-4 py-2 font-medium">预测</th>
|
||||
<th className="px-4 py-2 font-medium">实际</th>
|
||||
<th className="px-4 py-2 font-medium">1X2</th>
|
||||
<th className="px-4 py-2 font-medium">状态</th>
|
||||
<th className="px-4 py-2 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(p => {
|
||||
const match = (p as any).match
|
||||
const matchDate = match?.match_date
|
||||
const homeName = match?.home_team_zh || match?.home_team || '?'
|
||||
const awayName = match?.away_team_zh || match?.away_team || '?'
|
||||
const actualHome = match?.home_goals
|
||||
const actualAway = match?.away_goals
|
||||
const hasActual = actualHome != null && actualAway != null
|
||||
const isExpanded = expandedId === p.id
|
||||
const predHit = p.settled && hasActual
|
||||
? (actualHome! > actualAway! ? '1' : actualHome! < actualAway! ? '2' : 'X') === p.pred_1x2
|
||||
: null
|
||||
return (
|
||||
<tr key={p.id} className="border-b border-ink-100 hover:bg-paper-100">
|
||||
<td className="px-4 py-3 text-2xs text-ink-400">{fmtDate(matchDate)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1 text-xs text-ink-800">
|
||||
<TeamSideTag side="home" />
|
||||
<span className="truncate max-w-[100px]">{homeName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs text-ink-600 mt-0.5">
|
||||
<TeamSideTag side="away" />
|
||||
<span className="truncate max-w-[100px]">{awayName}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-serif font-bold text-ink-900">
|
||||
{p.pred_home_goals != null && p.pred_away_goals != null
|
||||
? `${p.pred_home_goals} : ${p.pred_away_goals}` : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{hasActual ? (
|
||||
<span className={`font-serif font-bold ${p.settled ? 'text-ink-900' : 'text-ink-400'}`}>
|
||||
{actualHome} : {actualAway}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-2xs text-ink-300">暂无赛果</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{p.pred_1x2 ? (
|
||||
<span className={`inline-block border px-1.5 py-0.5 text-2xs ${
|
||||
predHit === true ? 'border-emerald-300 text-emerald-700 bg-emerald-50' :
|
||||
predHit === false ? 'border-press text-press bg-press-wash' :
|
||||
'border-ink-200 text-ink-600'
|
||||
}`}>
|
||||
{OUTCOME_LABEL[p.pred_1x2] ?? p.pred_1x2}
|
||||
{predHit === true && ' ✓'}
|
||||
{predHit === false && ' ✗'}
|
||||
</span>
|
||||
) : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{p.settled ? (
|
||||
<Badge status="success">已结算</Badge>
|
||||
) : hasActual ? (
|
||||
<Badge status="info">可结算</Badge>
|
||||
) : (
|
||||
<Badge status="warning">待赛果</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex gap-2">
|
||||
{!p.settled && hasActual && (
|
||||
<button
|
||||
onClick={() => handleSettle(p)}
|
||||
disabled={settlingId === p.id}
|
||||
className="btn btn-sm btn-solid"
|
||||
>
|
||||
{settlingId === p.id ? <Spinner /> : '结算'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setExpandedId(isExpanded ? null : p.id)}
|
||||
className="btn btn-sm"
|
||||
>
|
||||
{isExpanded ? '收起' : '详情'}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 展开详情 */}
|
||||
{expandedId && (() => {
|
||||
const p = predictions.find(pr => pr.id === expandedId)
|
||||
if (!p) return null
|
||||
const match = (p as any).match
|
||||
const reports = p.agent_outputs ?? []
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader title="预测详情" />
|
||||
<CardBody className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm text-ink-700">
|
||||
<span>{fmtDate(match?.match_date)}</span>
|
||||
<span className="text-ink-300">|</span>
|
||||
<TeamSideTag side="home" />
|
||||
<span>{match?.home_team_zh || match?.home_team || '?'}</span>
|
||||
<span className="text-ink-400">vs</span>
|
||||
<TeamSideTag side="away" />
|
||||
<span>{match?.away_team_zh || match?.away_team || '?'}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="border-t-2 border-ink-900 pt-3">
|
||||
<p className="text-2xs text-ink-400">预测比分</p>
|
||||
<p className="mt-1 font-serif text-xl font-bold text-ink-900">
|
||||
{p.pred_home_goals != null && p.pred_away_goals != null
|
||||
? `${p.pred_home_goals} : ${p.pred_away_goals}` : '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-t-2 border-ink-900 pt-3">
|
||||
<p className="text-2xs text-ink-400">实际比分</p>
|
||||
<p className="mt-1 font-serif text-xl font-bold text-ink-900">
|
||||
{match?.home_goals != null && match?.away_goals != null
|
||||
? `${match.home_goals} : ${match.away_goals}` : '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-t-2 border-ink-900 pt-3">
|
||||
<p className="text-2xs text-ink-400">置信度</p>
|
||||
<p className="mt-1 font-serif text-xl font-bold text-ink-900">
|
||||
{p.subjective_confidence != null ? `${(p.subjective_confidence * 100).toFixed(0)}%` : '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{p.reasoning && (
|
||||
<div>
|
||||
<h4 className="section-head mb-2">终裁意见</h4>
|
||||
<blockquote className="border-l-2 border-press pl-4">
|
||||
<p className="whitespace-pre-wrap font-serif text-sm leading-loose text-ink-700">{p.reasoning}</p>
|
||||
</blockquote>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{reports.length > 0 && (
|
||||
<div>
|
||||
<h4 className="section-head mb-2">专家意见</h4>
|
||||
<div className="space-y-2">
|
||||
{reports.map((r) => (
|
||||
<div key={r.agent} className="border-b border-ink-100 pb-2 last:border-b-0">
|
||||
<p className="text-xs font-medium text-ink-700">{r.agent}</p>
|
||||
<p className="mt-0.5 text-xs text-ink-500">{r.analysis || '—'}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
/**
|
||||
* Admin 后台 - 预测管理页面(报刊风)
|
||||
*
|
||||
* 功能:
|
||||
* - 触发预测(选择比赛 + 模式)
|
||||
* - 结算:录入实际比分,写入评估(接 /eval/settle)
|
||||
* - 预测记录列表:可展开查看终裁理由与专家摘要
|
||||
*
|
||||
* 响应式布局: 移动端单列,桌面端双列
|
||||
*/
|
||||
|
||||
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: '阵容完整性分析专家',
|
||||
}
|
||||
|
||||
const OUTCOME_LABEL: Record<string, string> = { '1': '主胜', X: '平局', '2': '客胜' }
|
||||
|
||||
function fmtTime(s?: string | null): string {
|
||||
if (!s) return '—'
|
||||
const d = new Date(s)
|
||||
return isNaN(d.getTime())
|
||||
? s
|
||||
: d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
export default function PredictionsPage() {
|
||||
const [matches, setMatches] = useState<Match[]>([])
|
||||
const [predictions, setPredictions] = useState<Prediction[]>([])
|
||||
const [matchId, setMatchId] = useState('')
|
||||
const [mode, setMode] = useState<'single' | 'multi'>('multi')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [successMsg, setSuccessMsg] = useState<string | null>(null)
|
||||
|
||||
// 结算表单
|
||||
const [settleId, setSettleId] = useState('')
|
||||
const [homeGoals, setHomeGoals] = useState('')
|
||||
const [awayGoals, setAwayGoals] = useState('')
|
||||
const [settling, setSettling] = useState(false)
|
||||
const [settleMsg, setSettleMsg] = useState<{ kind: 'error' | 'ok'; text: string } | null>(null)
|
||||
|
||||
const refreshPredictions = useCallback(async () => {
|
||||
const list = await fetchPredictions(50)
|
||||
setPredictions(list)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
refreshPredictions()
|
||||
fetchMatches({ limit: 100 }).then(d => setMatches(d.items))
|
||||
}, [refreshPredictions])
|
||||
|
||||
/** match_id → 中文名对阵 */
|
||||
const matchName = useMemo(() => {
|
||||
const map = new Map<number, string>()
|
||||
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}`)
|
||||
}
|
||||
return map
|
||||
}, [matches])
|
||||
|
||||
const nameOf = (id: number) => matchName.get(id) ?? `比赛 #${id}`
|
||||
|
||||
async function handlePredict(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!matchId) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setSuccessMsg(null)
|
||||
try {
|
||||
await triggerPrediction({ match_id: parseInt(matchId), mode })
|
||||
setSuccessMsg('预测任务已完成,记录已更新')
|
||||
await refreshPredictions()
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : '预测失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const unsettled = predictions.filter(p => !p.settled)
|
||||
|
||||
async function handleSettle(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
const pid = parseInt(settleId)
|
||||
const hg = parseInt(homeGoals)
|
||||
const ag = parseInt(awayGoals)
|
||||
if (!pid || isNaN(hg) || isNaN(ag)) return
|
||||
setSettling(true)
|
||||
setSettleMsg(null)
|
||||
try {
|
||||
await settlePrediction(pid, hg, ag)
|
||||
setSettleMsg({ kind: 'ok', text: '结算完成,准确率统计已更新' })
|
||||
setSettleId('')
|
||||
setHomeGoals('')
|
||||
setAwayGoals('')
|
||||
await refreshPredictions()
|
||||
} catch (err: unknown) {
|
||||
setSettleMsg({
|
||||
kind: 'error',
|
||||
text: err instanceof Error ? err.message : '结算失败',
|
||||
})
|
||||
} finally {
|
||||
setSettling(false)
|
||||
}
|
||||
}
|
||||
|
||||
const settleTarget = predictions.find(p => p.id === parseInt(settleId))
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SectionHeader
|
||||
title="预测管理"
|
||||
description="触发 LLM 预测;赛后录入实际比分完成结算,供准确率统计使用。"
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* 新建预测 */}
|
||||
<Card>
|
||||
<CardHeader title="新建预测" />
|
||||
<CardBody>
|
||||
<form onSubmit={handlePredict} className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">比赛</label>
|
||||
<select
|
||||
value={matchId}
|
||||
onChange={e => setMatchId(e.target.value)}
|
||||
className="field w-full"
|
||||
>
|
||||
<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.match_date?.slice(5, 10)})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">模式</label>
|
||||
<select
|
||||
value={mode}
|
||||
onChange={e => setMode(e.target.value as 'single' | 'multi')}
|
||||
className="field w-full"
|
||||
>
|
||||
<option value="multi">多专家 (5 路 + 终裁,慢而稳)</option>
|
||||
<option value="single">单次调用 (快)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && <Alert kind="error" title="预测失败" message={error} onClose={() => setError(null)} />}
|
||||
{successMsg && (
|
||||
<Alert kind="ok" title={successMsg} onClose={() => setSuccessMsg(null)} />
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !matchId}
|
||||
className="btn btn-solid w-full"
|
||||
>
|
||||
{loading ? (<><Spinner /> 预测中,多专家模式约需 20-60 秒</>) : '触发预测'}
|
||||
</button>
|
||||
</form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 结算 */}
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="预测结算"
|
||||
description="录入实际比分,系统据此统计 1X2 准确率与比分 RMSE"
|
||||
/>
|
||||
<CardBody>
|
||||
{unsettled.length === 0 ? (
|
||||
<p className="py-6 text-center text-xs text-ink-400">
|
||||
没有待结算的预测记录。预测完成后可在此录入实际比分。
|
||||
</p>
|
||||
) : (
|
||||
<form onSubmit={handleSettle} className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">预测记录</label>
|
||||
<select
|
||||
value={settleId}
|
||||
onChange={e => setSettleId(e.target.value)}
|
||||
className="field w-full"
|
||||
>
|
||||
<option value="">选择待结算预测({unsettled.length} 条)</option>
|
||||
{unsettled.map(p => (
|
||||
<option key={p.id} value={p.id}>
|
||||
#{p.id} {nameOf(p.match_id)} · 预测 {p.pred_home_goals ?? '-'}:{p.pred_away_goals ?? '-'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{settleTarget && (
|
||||
<p className="border-l-2 border-ink-300 pl-3 text-2xs text-ink-500">
|
||||
预测:{settleTarget.pred_home_goals ?? '-'} : {settleTarget.pred_away_goals ?? '-'}
|
||||
({OUTCOME_LABEL[settleTarget.pred_1x2 ?? ''] ?? '?'})
|
||||
<span className="ml-2">{fmtTime(settleTarget.created_at)}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">主队实际进球</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={30}
|
||||
value={homeGoals}
|
||||
onChange={e => setHomeGoals(e.target.value)}
|
||||
className="field w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">客队实际进球</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={30}
|
||||
value={awayGoals}
|
||||
onChange={e => setAwayGoals(e.target.value)}
|
||||
className="field w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{settleMsg && (
|
||||
<Alert
|
||||
kind={settleMsg.kind}
|
||||
title={settleMsg.kind === 'ok' ? '结算完成' : '结算失败'}
|
||||
message={settleMsg.kind === 'error' ? settleMsg.text : undefined}
|
||||
onClose={() => setSettleMsg(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={settling || !settleId || homeGoals === '' || awayGoals === ''}
|
||||
className="btn btn-solid w-full"
|
||||
>
|
||||
{settling ? (<><Spinner /> 结算中</>) : '提交结算'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 最近预测 */}
|
||||
<Card>
|
||||
<CardHeader title="预测记录" description="点击行可展开终裁理由与专家摘要" />
|
||||
<CardBody className="px-0 sm:px-0">
|
||||
{predictions.length === 0 ? (
|
||||
<p className="py-10 text-center text-xs text-ink-400">
|
||||
暂无预测记录,触发预测后将在此显示
|
||||
</p>
|
||||
) : (
|
||||
<div>
|
||||
{predictions.map(p => {
|
||||
const okAgents = (p.agent_outputs ?? []).filter(a => a.status === 'ok')
|
||||
return (
|
||||
<details key={p.id} className="group border-b border-ink-200 last:border-b-0">
|
||||
<summary className="flex cursor-pointer list-none flex-wrap items-baseline gap-x-3 gap-y-1 px-4 py-3 transition-colors hover:bg-paper-100 sm:px-5">
|
||||
<span className="text-2xs tabular-nums text-ink-400">#{p.id}</span>
|
||||
<span className="text-sm font-medium text-ink-900">{nameOf(p.match_id)}</span>
|
||||
<span className="font-serif text-sm font-bold tabular-nums text-ink-900">
|
||||
{p.pred_home_goals ?? '-'}<span className="mx-0.5 font-normal text-ink-300">:</span>{p.pred_away_goals ?? '-'}
|
||||
</span>
|
||||
<span className="text-2xs text-ink-500">
|
||||
{OUTCOME_LABEL[p.pred_1x2 ?? ''] ?? '—'}
|
||||
{p.subjective_confidence !== null && p.subjective_confidence !== undefined &&
|
||||
` · ${Math.round(p.subjective_confidence * 100)}%`}
|
||||
</span>
|
||||
<span className="ml-auto flex items-baseline gap-3">
|
||||
{p.settled ? (
|
||||
<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>
|
||||
<svg
|
||||
viewBox="0 0 20 20"
|
||||
className="h-3 w-3 self-center text-ink-300 transition-transform group-open:rotate-90"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M7.3 5.3a1 1 0 011.4 0l4 4a1 1 0 010 1.4l-4 4a1 1 0 01-1.4-1.4L10.6 10 7.3 6.7a1 1 0 010-1.4z" />
|
||||
</svg>
|
||||
</span>
|
||||
</summary>
|
||||
|
||||
<div className="space-y-3 px-4 pb-4 pl-8 sm:px-6 sm:pl-9">
|
||||
<p className="text-2xs text-ink-500">
|
||||
{p.mode === 'multi' ? `多专家 · ${okAgents.length}/${p.agent_outputs?.length ?? 0} 路有效` : '单次模式'}
|
||||
{p.model && <span className="ml-2 font-mono">{p.model}</span>}
|
||||
</p>
|
||||
|
||||
{p.reasoning && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{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) => (
|
||||
<li key={i} className="flex items-baseline gap-2.5 text-xs">
|
||||
<span className={`inline-block h-1.5 w-1.5 flex-shrink-0 self-center ${a.status === 'ok' ? 'bg-ink-900' : 'bg-ink-300'}`} aria-hidden="true" />
|
||||
<span className="text-ink-800">{AGENT_LABELS[a.agent] ?? a.agent}</span>
|
||||
{a.probable_score && (
|
||||
<span className="font-serif font-bold tabular-nums text-ink-800">{a.probable_score}</span>
|
||||
)}
|
||||
<span className="text-2xs text-ink-400">
|
||||
{a.status === 'ok' ? '' : a.status === 'no_data' ? '无数据' : '失败'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{p.settled && (
|
||||
<p className="border-t border-ink-100 pt-2.5 text-2xs text-ink-500">
|
||||
实际比分 {p.actual_home_goals ?? '-'} : {p.actual_away_goals ?? '-'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
/**
|
||||
* Admin 后台 - 统一设置页(报刊风)
|
||||
*
|
||||
* 合并原「数据源」「LLM 配置」「系统配置」三页:
|
||||
* 1. 数据源 — bzzoiro API Key + Key Ring 状态
|
||||
* 2. LLM — 连接配置 + 使用统计 + 专家独立配置
|
||||
* 3. 认证 — 修改密码
|
||||
* 4. 系统 — 配置查看 + 修改指南
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import {
|
||||
fetchSettings, updateSetting, clearSetting,
|
||||
testLLMConnection, fetchLLMUsageStats, fetchLLMModels,
|
||||
fetchKeyRingStatus, resetKeyRingCooldown,
|
||||
fetchSchedules, createSchedule, updateSchedule, deleteSchedule, runScheduleNow,
|
||||
} from '../dal'
|
||||
import type { ScheduleItem } from '../dal'
|
||||
import { changePassword, fetchAuthState, UNAUTHORIZED_EVENT } from '../api'
|
||||
import type { LLMUsageStats, DataSourceSetting } from '../types'
|
||||
import type { KeyRingStatusResponse } from '../dal'
|
||||
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components'
|
||||
import SettingRow from '../SettingRow'
|
||||
import AgentLLMCard from '../AgentLLMCard'
|
||||
|
||||
const DATA_SOURCE_KEYS = ['BZZOIRO_KEY', 'BZZOIRO_BASE']
|
||||
const LLM_KEYS = ['LLM_API_KEY', 'LLM_BASE_URL', 'LLM_MODEL']
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [allSettings, setAllSettings] = useState<DataSourceSetting[]>([])
|
||||
const [settingsLoading, setSettingsLoading] = useState(true)
|
||||
const [editingKey, setEditingKey] = useState<string | null>(null)
|
||||
const [busyKey, setBusyKey] = useState<string | null>(null)
|
||||
const [rowNotice, setRowNotice] = useState<{ key: string; ok: boolean; text: string } | null>(null)
|
||||
|
||||
// LLM
|
||||
const [llmStats, setLlmStats] = useState<LLMUsageStats | null>(null)
|
||||
const [llmLoading, setLlmLoading] = useState(true)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null)
|
||||
|
||||
// Key Ring
|
||||
const [keyRing, setKeyRing] = useState<KeyRingStatusResponse | null>(null)
|
||||
const [ringLoading, setRingLoading] = useState(false)
|
||||
|
||||
// 密码
|
||||
const [passwordOrigin, setPasswordOrigin] = useState<'db' | 'env' | 'none' | null>(null)
|
||||
const [currentPwd, setCurrentPwd] = useState('')
|
||||
const [newPwd, setNewPwd] = useState('')
|
||||
const [confirmPwd, setConfirmPwd] = useState('')
|
||||
const [pwdBusy, setPwdBusy] = useState(false)
|
||||
const [pwdNotice, setPwdNotice] = useState<{ ok: boolean; text: string } | null>(null)
|
||||
|
||||
// 定时任务
|
||||
const [schedules, setSchedules] = useState<ScheduleItem[]>([])
|
||||
const [schedulesLoading, setSchedulesLoading] = useState(true)
|
||||
const [scheduleNotice, setScheduleNotice] = useState<{ ok: boolean; text: string } | null>(null)
|
||||
const [scheduleBusyId, setScheduleBusyId] = useState<string | null>(null)
|
||||
|
||||
// ── 数据加载 ──
|
||||
const loadSettings = useCallback(async () => {
|
||||
setSettingsLoading(true)
|
||||
try {
|
||||
const all = await fetchSettings()
|
||||
setAllSettings(all)
|
||||
} catch {
|
||||
setAllSettings([])
|
||||
} finally {
|
||||
setSettingsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadLlmStats = useCallback(async () => {
|
||||
setLlmLoading(true)
|
||||
try {
|
||||
setLlmStats(await fetchLLMUsageStats())
|
||||
} catch {
|
||||
setLlmStats(null)
|
||||
} finally {
|
||||
setLlmLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadKeyRing = useCallback(async () => {
|
||||
setRingLoading(true)
|
||||
try {
|
||||
setKeyRing(await fetchKeyRingStatus())
|
||||
} catch {
|
||||
setKeyRing(null)
|
||||
} finally {
|
||||
setRingLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings()
|
||||
loadLlmStats()
|
||||
loadKeyRing()
|
||||
fetchAuthState().then(s => setPasswordOrigin(s.password_origin ?? null)).catch(() => {})
|
||||
fetchSchedules().then(setSchedules).catch(() => []).finally(() => setSchedulesLoading(false))
|
||||
}, [loadSettings, loadLlmStats, loadKeyRing])
|
||||
|
||||
const dataSourceSettings = allSettings.filter(s => DATA_SOURCE_KEYS.includes(s.key))
|
||||
const llmSettings = allSettings.filter(s => LLM_KEYS.includes(s.key))
|
||||
|
||||
// ── 操作 ──
|
||||
const handleSave = async (key: string, value: string) => {
|
||||
setBusyKey(key)
|
||||
setRowNotice(null)
|
||||
try {
|
||||
await updateSetting(key, value)
|
||||
setRowNotice({ key, ok: true, text: '已保存,立即生效' })
|
||||
setEditingKey(null)
|
||||
await loadSettings()
|
||||
if (key === 'BZZOIRO_KEY') await loadKeyRing()
|
||||
} catch (err) {
|
||||
setRowNotice({ key, ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '保存失败' })
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClear = async (key: string) => {
|
||||
setBusyKey(key)
|
||||
setRowNotice(null)
|
||||
try {
|
||||
await clearSetting(key)
|
||||
setRowNotice({ key, ok: true, text: '已清除数据库覆盖,回落 .env 默认值' })
|
||||
await loadSettings()
|
||||
} catch (err) {
|
||||
setRowNotice({ key, ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '清除失败' })
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 定时任务操作 ──
|
||||
const handleToggleSchedule = async (s: ScheduleItem) => {
|
||||
setScheduleBusyId(s.id)
|
||||
setScheduleNotice(null)
|
||||
try {
|
||||
await updateSchedule(s.id, { enabled: !s.enabled })
|
||||
setScheduleNotice({ ok: true, text: `已${!s.enabled ? '启用' : '禁用'}任务「${s.id}」` })
|
||||
setSchedules(prev => prev.map(x => x.id === s.id ? { ...x, enabled: !x.enabled } : x))
|
||||
} catch {
|
||||
setScheduleNotice({ ok: false, text: '操作失败' })
|
||||
} finally {
|
||||
setScheduleBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRunSchedule = async (s: ScheduleItem) => {
|
||||
setScheduleBusyId(s.id)
|
||||
setScheduleNotice(null)
|
||||
try {
|
||||
await runScheduleNow(s.id)
|
||||
setScheduleNotice({ ok: true, text: `任务「${s.id}」已启动,请在日志页查看进度` })
|
||||
} catch {
|
||||
setScheduleNotice({ ok: false, text: '启动失败' })
|
||||
} finally {
|
||||
setScheduleBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteSchedule = async (s: ScheduleItem) => {
|
||||
setScheduleBusyId(s.id)
|
||||
setScheduleNotice(null)
|
||||
try {
|
||||
await deleteSchedule(s.id)
|
||||
setScheduleNotice({ ok: true, text: `已删除任务「${s.id}」` })
|
||||
setSchedules(prev => prev.filter(x => x.id !== s.id))
|
||||
} catch {
|
||||
setScheduleNotice({ ok: false, text: '删除失败' })
|
||||
} finally {
|
||||
setScheduleBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const detectLLMModels = useCallback(async (): Promise<string[]> => {
|
||||
const r = await fetchLLMModels()
|
||||
if (!r.ok) throw new Error(r.detail)
|
||||
return r.models
|
||||
}, [])
|
||||
|
||||
const handleTest = async () => {
|
||||
setTesting(true)
|
||||
setTestResult(null)
|
||||
try {
|
||||
await testLLMConnection()
|
||||
setTestResult({ success: true, message: 'LLM 连接测试成功' })
|
||||
} catch (err: unknown) {
|
||||
setTestResult({ success: false, message: err instanceof Error ? err.message : 'LLM 连接测试失败' })
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleResetCooldown = async () => {
|
||||
if (!window.confirm('确定重置所有 key 的冷却状态?这可能使被限流的 key 立即恢复请求。')) return
|
||||
try {
|
||||
const res = await resetKeyRingCooldown()
|
||||
setKeyRing(res.stats)
|
||||
setRowNotice({ key: "__ring", ok: true, text: res.message })
|
||||
} catch (err) {
|
||||
setRowNotice({ key: "__ring", ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '重置失败' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleChangePassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setPwdNotice(null)
|
||||
if (newPwd !== confirmPwd) {
|
||||
setPwdNotice({ ok: false, text: '两次输入的新密码不一致' })
|
||||
return
|
||||
}
|
||||
setPwdBusy(true)
|
||||
try {
|
||||
const res = await changePassword(currentPwd, newPwd)
|
||||
setPwdNotice({ ok: true, text: res.message })
|
||||
setTimeout(() => window.dispatchEvent(new CustomEvent(UNAUTHORIZED_EVENT)), 1500)
|
||||
} catch (err) {
|
||||
setPwdNotice({ ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '修改失败' })
|
||||
} finally {
|
||||
setPwdBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<SectionHeader
|
||||
title="系统设置"
|
||||
description="数据源、LLM、认证等全部配置。保存到数据库并立即生效,优先于 .env。"
|
||||
/>
|
||||
|
||||
{/* ── 1. 数据源 ── */}
|
||||
<section>
|
||||
<h2 className="section-head mb-3">数据源</h2>
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Bzzoiro API"
|
||||
description="赛程 / 比分 / 积分榜 / 比赛统计的唯一数据源"
|
||||
action={<button onClick={loadSettings} disabled={settingsLoading} className="btn btn-sm">{settingsLoading ? <><Spinner /> 加载中</> : '刷新'}</button>}
|
||||
/>
|
||||
<CardBody>
|
||||
{settingsLoading ? (
|
||||
<div className="space-3">{dataSourceSettings.map((_, i) => <SkeletonBlock key={i} className="h-9 w-full" />)}</div>
|
||||
) : (
|
||||
dataSourceSettings.map(setting => (
|
||||
<SettingRow
|
||||
key={setting.key}
|
||||
setting={setting}
|
||||
editing={editingKey === setting.key}
|
||||
busy={busyKey === setting.key}
|
||||
onEdit={() => { setEditingKey(setting.key); setRowNotice(null) }}
|
||||
onCancel={() => setEditingKey(null)}
|
||||
onSave={v => handleSave(setting.key, v)}
|
||||
onClear={() => handleClear(setting.key)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
{rowNotice && !rowNotice.key.startsWith('__') && dataSourceSettings.some(s => s.key === rowNotice.key) && (
|
||||
<div className="mt-3"><Alert kind={rowNotice.ok ? 'ok' : 'error'} title={rowNotice.text} /></div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* Key Ring */}
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="API Key 轮换环"
|
||||
description={keyRing?.has_multiple ? `已配置 ${keyRing.total} 个 key,遇限流自动切换` : '当前仅 1 个 key,无法轮换'}
|
||||
action={<button onClick={handleResetCooldown} disabled={ringLoading} className="btn-sm btn-outline">重置冷却</button>}
|
||||
/>
|
||||
<CardBody>
|
||||
{keyRing && keyRing.total > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{keyRing.keys.map((k, i) => {
|
||||
const isBlocked = k.blocked_remaining > 0
|
||||
return (
|
||||
<div key={i} className={`flex items-center justify-between gap-3 border-b border-ink-100 py-2 last:border-b-0 ${isBlocked ? 'opacity-70' : ''}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-block h-2 w-2 rounded-full ${isBlocked ? 'bg-amber-500' : 'bg-emerald-500'}`} />
|
||||
<span className="font-mono text-xs text-ink-700">{k.masked}</span>
|
||||
{i === keyRing.active_index && <span className="rounded bg-ink-900 px-1.5 py-0.5 text-2xs text-paper-500">当前</span>}
|
||||
</div>
|
||||
<span className={`text-2xs tabular-nums ${isBlocked ? 'text-amber-600' : 'text-ink-400'}`}>
|
||||
{isBlocked ? `冷却中 ${k.blocked_remaining.toFixed(0)}s` : '可用'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-ink-400">暂无 key 配置</p>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── 2. LLM ── */}
|
||||
<section>
|
||||
<h2 className="section-head mb-3">大语言模型</h2>
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="连接配置"
|
||||
description="OpenAI 兼容接口(DeepSeek / 智谱 / 通义等)"
|
||||
action={<button onClick={loadSettings} disabled={settingsLoading} className="btn btn-sm">{settingsLoading ? <><Spinner /> 加载中</> : '刷新'}</button>}
|
||||
/>
|
||||
<CardBody>
|
||||
{settingsLoading ? (
|
||||
<div className="space-y-3">{llmSettings.map((_, i) => <SkeletonBlock key={i} className="h-9 w-full" />)}</div>
|
||||
) : (
|
||||
llmSettings.map(setting => (
|
||||
<SettingRow
|
||||
key={setting.key}
|
||||
setting={setting}
|
||||
editing={editingKey === setting.key}
|
||||
busy={busyKey === setting.key}
|
||||
onEdit={() => { setEditingKey(setting.key); setRowNotice(null) }}
|
||||
onCancel={() => setEditingKey(null)}
|
||||
onSave={v => handleSave(setting.key, v)}
|
||||
onClear={() => handleClear(setting.key)}
|
||||
detectModels={setting.key === 'LLM_MODEL' ? detectLLMModels : undefined}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
{rowNotice && !rowNotice.key.startsWith('__') && llmSettings.some(s => s.key === rowNotice.key) && (
|
||||
<div className="mt-3"><Alert kind={rowNotice.ok ? 'ok' : 'error'} title={rowNotice.text} /></div>
|
||||
)}
|
||||
{testResult && (
|
||||
<div className="mt-4">
|
||||
<Alert kind={testResult.success ? 'ok' : 'error'} title={testResult.success ? '连接正常' : '连接失败'} message={testResult.success ? undefined : testResult.message} />
|
||||
</div>
|
||||
)}
|
||||
<button onClick={handleTest} disabled={testing} className="btn btn-sm mt-4 w-full">
|
||||
{testing ? <><Spinner /> 测试中</> : '测试 LLM 连接'}
|
||||
</button>
|
||||
<p className="mt-2 text-center text-2xs text-ink-400">测试会真实调用一次 LLM 预测,产生费用。</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="使用统计" description="从最近预测记录聚合" action={<button onClick={loadLlmStats} disabled={llmLoading} className="btn btn-sm">{llmLoading ? <><Spinner /> 加载中</> : '刷新'}</button>} />
|
||||
<CardBody>
|
||||
{llmLoading ? (
|
||||
<div className="space-y-3"><SkeletonBlock className="h-16 w-full" /><SkeletonBlock className="h-16 w-full" /></div>
|
||||
) : llmStats ? (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="border-t-2 border-ink-900 pt-3 text-center">
|
||||
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">{llmStats.total_predictions}</div>
|
||||
<div className="mt-1 text-2xs text-ink-400">总预测数</div>
|
||||
</div>
|
||||
<div className="border-t-2 border-ink-900 pt-3 text-center">
|
||||
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">{llmStats.avg_latency_ms > 0 ? `${(llmStats.avg_latency_ms / 1000).toFixed(1)}s` : '—'}</div>
|
||||
<div className="mt-1 text-2xs text-ink-400">平均延迟</div>
|
||||
</div>
|
||||
<div className="border-t-2 border-press pt-3 text-center">
|
||||
<div className="font-serif text-2xl font-bold tabular-nums text-press">{llmStats.success_rate.toFixed(0)}%</div>
|
||||
<div className="mt-1 text-2xs text-ink-400">有效率</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-6 text-center text-xs text-ink-400">暂无使用统计数据</p>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<AgentLLMCard />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── 3. 认证 ── */}
|
||||
<section>
|
||||
<h2 className="section-head mb-3">登录认证</h2>
|
||||
<Card>
|
||||
<CardHeader title="修改密码" description="密码即会话签名密钥,修改后所有已登录会话失效,需重新登录" />
|
||||
<CardBody>
|
||||
{passwordOrigin && (
|
||||
<p className="mb-3 flex items-center gap-2 text-2xs text-ink-500">
|
||||
当前密码来源:
|
||||
{passwordOrigin === 'db' ? <Badge status="success">数据库(scrypt 哈希)</Badge>
|
||||
: passwordOrigin === 'env' ? <Badge status="info">.env 初始值</Badge>
|
||||
: <Badge status="error">未配置</Badge>}
|
||||
</p>
|
||||
)}
|
||||
<form onSubmit={handleChangePassword} className="space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-2xs text-ink-500">当前密码</label>
|
||||
<input type="password" value={currentPwd} onChange={e => setCurrentPwd(e.target.value)} autoComplete="current-password" className="field w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-2xs text-ink-500">新密码(至少 8 位)</label>
|
||||
<input type="password" value={newPwd} onChange={e => setNewPwd(e.target.value)} autoComplete="new-password" className="field w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-2xs text-ink-500">确认新密码</label>
|
||||
<input type="password" value={confirmPwd} onChange={e => setConfirmPwd(e.target.value)} autoComplete="new-password" className="field w-full" />
|
||||
</div>
|
||||
</div>
|
||||
{pwdNotice && <Alert kind={pwdNotice.ok ? 'ok' : 'error'} title={pwdNotice.text} />}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-2xs text-ink-400">修改成功后会自动退出登录。</p>
|
||||
<button type="submit" disabled={pwdBusy || !currentPwd || !newPwd || !confirmPwd} className="btn btn-solid btn-sm flex-shrink-0">
|
||||
{pwdBusy ? <><Spinner /> 修改中</> : '修改密码'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* ── 4. 定时任务 ── */}
|
||||
<section>
|
||||
<h2 className="section-head mb-3">定时任务</h2>
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="采集调度"
|
||||
description="配置 cron 表达式定时触发采集任务"
|
||||
action={
|
||||
<button
|
||||
onClick={() => {
|
||||
createSchedule({ id: `schedule-${Date.now()}`, task: 'events', cron: '0 8 * * *', leagues: undefined, enabled: false })
|
||||
.then(() => fetchSchedules().then(setSchedules))
|
||||
}}
|
||||
className="btn btn-sm"
|
||||
>
|
||||
+ 新建
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<CardBody>
|
||||
{scheduleNotice && (
|
||||
<div className="mb-3">
|
||||
<Alert kind={scheduleNotice.ok ? 'ok' : 'error'} title={scheduleNotice.text} onClose={() => setScheduleNotice(null)} />
|
||||
</div>
|
||||
)}
|
||||
{schedulesLoading ? (
|
||||
<SkeletonBlock className="h-10 w-full" />
|
||||
) : schedules.length === 0 ? (
|
||||
<p className="py-6 text-center text-xs text-ink-400">暂无定时任务,点击右上角「+ 新建」创建</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{schedules.map(s => (
|
||||
<div key={s.id} className="flex flex-col gap-2 border-b border-ink-100 py-3 last:border-b-0 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-block h-2 w-2 rounded-full ${s.enabled ? 'bg-emerald-500' : 'bg-ink-300'}`} />
|
||||
<span className="text-xs font-medium text-ink-800">{s.id}</span>
|
||||
<Badge status={s.enabled ? 'success' : 'muted'}>{s.task}</Badge>
|
||||
</div>
|
||||
<p className="font-mono text-2xs text-ink-500">{s.cron}</p>
|
||||
{s.last_run_at && (
|
||||
<p className="text-2xs text-ink-400">
|
||||
上次: {new Date(s.last_run_at).toLocaleString('zh-CN', { hour12: false })}
|
||||
{s.last_status === 'success' ? ' ✓' : s.last_status === 'failed' ? ' ✗' : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleRunSchedule(s)}
|
||||
disabled={scheduleBusyId === s.id}
|
||||
className="btn btn-sm"
|
||||
>
|
||||
{scheduleBusyId === s.id ? <Spinner /> : '立即执行'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleToggleSchedule(s)}
|
||||
disabled={scheduleBusyId === s.id}
|
||||
className={`btn btn-sm ${s.enabled ? '' : 'btn-solid'}`}
|
||||
>
|
||||
{scheduleBusyId === s.id ? <Spinner /> : (s.enabled ? '禁用' : '启用')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteSchedule(s)}
|
||||
disabled={scheduleBusyId === s.id}
|
||||
className="btn btn-sm btn-danger"
|
||||
>
|
||||
{scheduleBusyId === s.id ? <Spinner /> : '删除'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Admin 后台 - 路由配置
|
||||
*
|
||||
* 所有 Admin 页面的路由定义,使用嵌套路由。
|
||||
* 挂载路径: /admin/*
|
||||
*/
|
||||
|
||||
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 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'
|
||||
|
||||
export const adminRoutes = [
|
||||
{
|
||||
path: '/admin',
|
||||
element: <AdminLayout />,
|
||||
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: '*', element: <Navigate to="/admin" replace /> },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export { adminRoutes as default }
|
||||
@@ -0,0 +1,388 @@
|
||||
/**
|
||||
* Admin 后台 - TypeScript 类型定义
|
||||
*
|
||||
* 与 FastAPI 后端 Pydantic 模型对齐
|
||||
*/
|
||||
|
||||
// ── 系统健康 ────────────────────────────────────────────────────
|
||||
|
||||
export interface HealthStatus {
|
||||
status: 'ok' | 'degraded' | 'error'
|
||||
version?: string
|
||||
uptime_seconds?: number
|
||||
checks: Record<string, 'pass' | 'fail' | 'warn'>
|
||||
}
|
||||
|
||||
// ── 仪表盘 ──────────────────────────────────────────────────────
|
||||
|
||||
export interface DashboardStats {
|
||||
leagues: League[]
|
||||
// 比赛总量已移除: 用 fetchAdminStats()(/admin/stats)的精确 COUNT,
|
||||
// 不要再用列表 items.length 近似(上限 100 会严重失真)
|
||||
total_predictions: number
|
||||
health: string
|
||||
db_tables: { name: string; row_count: number; size_mb: number; last_updated: string | null }[]
|
||||
last_collection: { source: string; league_code: string | null; started_at: string; finished_at: string | null; status: string; records_count: number | null; error_message: string | null }[]
|
||||
recent_errors: { id: number; timestamp: string; source: string; message: string; level: string }[]
|
||||
}
|
||||
|
||||
// ── 联赛 & 比赛 ─────────────────────────────────────────────────
|
||||
|
||||
export interface League {
|
||||
id?: number
|
||||
code: string
|
||||
name: string
|
||||
name_zh?: string
|
||||
country?: string
|
||||
}
|
||||
|
||||
export interface Match {
|
||||
id: number
|
||||
league_code?: string
|
||||
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
|
||||
}
|
||||
|
||||
// ── 预测 ────────────────────────────────────────────────────────
|
||||
|
||||
/** 列表接口返回的单路专家摘要字段 */
|
||||
export interface PredictionAgentOutput {
|
||||
agent: string
|
||||
status: string
|
||||
analysis?: string | null
|
||||
probable_score?: string | null
|
||||
subjective_confidence?: number | null
|
||||
}
|
||||
|
||||
export interface Prediction {
|
||||
id: number
|
||||
match_id: number
|
||||
provider: string
|
||||
model: string
|
||||
prompt_version?: string
|
||||
mode?: string
|
||||
pred_home_goals?: number | null
|
||||
pred_away_goals?: number | null
|
||||
pred_1x2?: string | null
|
||||
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
|
||||
settled?: boolean
|
||||
}
|
||||
|
||||
export interface PredictRequest {
|
||||
match_id: number
|
||||
mode?: 'single' | 'multi'
|
||||
provider?: string
|
||||
model?: string
|
||||
}
|
||||
|
||||
// ── 数据采集 ────────────────────────────────────────────────────
|
||||
|
||||
export interface CollectionRequest {
|
||||
status?: string
|
||||
source: 'bzzoiro'
|
||||
leagues?: string[]
|
||||
task?: 'events' | 'standings' | 'stats' | 'all'
|
||||
limit?: number
|
||||
season?: string
|
||||
date_from?: string
|
||||
date_to?: string
|
||||
}
|
||||
|
||||
// 采集任务状态(ingest_jobs,提交采集后轮询)
|
||||
export interface IngestJob {
|
||||
id: string
|
||||
task: 'events' | 'standings' | 'stats' | 'all'
|
||||
params: Record<string, unknown>
|
||||
status: 'pending' | 'running' | 'success' | 'failed'
|
||||
result: Record<string, any> | null
|
||||
error: string | null
|
||||
created_at: string | null
|
||||
started_at: string | null
|
||||
finished_at: string | null
|
||||
}
|
||||
|
||||
// ── 评估 & 回测 ─────────────────────────────────────────────────
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export interface BacktestRequest {
|
||||
league_id?: number
|
||||
date_from?: string
|
||||
date_to?: string
|
||||
mode?: 'single' | 'multi'
|
||||
limit?: number
|
||||
model?: string
|
||||
}
|
||||
|
||||
export interface BacktestSummary {
|
||||
total: number
|
||||
scored: number
|
||||
success: number
|
||||
degraded: number
|
||||
accuracy_1x2?: number
|
||||
avg_score_rmse?: number
|
||||
avg_subjective_confidence?: number
|
||||
}
|
||||
|
||||
// ── 数据源配置 ──────────────────────────────────────────────────
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export interface DataSourceTestRequest {
|
||||
source: 'bzzoiro' | 'understat' | 'injuries'
|
||||
}
|
||||
|
||||
export interface IngestionHistoryEntry {
|
||||
id: string
|
||||
source: string
|
||||
started_at: string
|
||||
finished_at: string | null
|
||||
status: 'success' | 'running' | 'failed'
|
||||
records_count: number | null
|
||||
error_message: string | null
|
||||
}
|
||||
|
||||
// ── LLM 配置 ────────────────────────────────────────────────────
|
||||
|
||||
export interface LLMConfig {
|
||||
provider: string
|
||||
model: string
|
||||
base_url: string
|
||||
api_key_configured: boolean
|
||||
api_key_masked: string
|
||||
}
|
||||
|
||||
export interface LLMUsageStats {
|
||||
total_predictions: number
|
||||
avg_latency_ms: number
|
||||
success_rate: number
|
||||
recent_predictions: Array<{
|
||||
id: number
|
||||
match_id: number
|
||||
model: string
|
||||
created_at: string
|
||||
latency_ms?: number
|
||||
status: 'success' | 'failed'
|
||||
}>
|
||||
}
|
||||
|
||||
// ── 系统配置 ────────────────────────────────────────────────────
|
||||
|
||||
export interface SystemConfigEntry {
|
||||
key: string
|
||||
value_masked: string
|
||||
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 }
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Admin 后台 - 键盘快捷键: Cmd+K 命令面板
|
||||
*
|
||||
* 提供全局快捷键:
|
||||
* Cmd/Ctrl+K — 打开命令面板(页面跳转)
|
||||
* / — 聚焦搜索(日志页)
|
||||
* r — 刷新当前页面数据(通用)
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
interface CommandItem {
|
||||
id: string
|
||||
label: string
|
||||
group: string
|
||||
action: () => void
|
||||
}
|
||||
|
||||
export function useCommandPalette(pages: Array<{ to: string; label: string; group: string }>) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const navigate = useNavigate()
|
||||
|
||||
const items: CommandItem[] = pages.map(p => ({
|
||||
id: p.to,
|
||||
label: p.label,
|
||||
group: p.group,
|
||||
action: () => { navigate(p.to); setOpen(false) },
|
||||
}))
|
||||
|
||||
const filtered = query
|
||||
? items.filter(i => i.label.toLowerCase().includes(query.toLowerCase()) || i.id.includes(query.toLowerCase()))
|
||||
: items
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
// Cmd/Ctrl+K → 命令面板
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault()
|
||||
setOpen(o => !o)
|
||||
setQuery('')
|
||||
}
|
||||
// Escape → 关闭
|
||||
if (e.key === 'Escape' && open) {
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handler)
|
||||
return () => document.removeEventListener('keydown', handler)
|
||||
}, [open])
|
||||
|
||||
return { open, setOpen, query, setQuery, items: filtered }
|
||||
}
|
||||
|
||||
/** 命令面板弹窗 */
|
||||
export function CommandPalette({
|
||||
open,
|
||||
query,
|
||||
setQuery,
|
||||
items,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean
|
||||
query: string
|
||||
setQuery: (q: string) => void
|
||||
items: CommandItem[]
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [selected, setSelected] = useState(0)
|
||||
|
||||
// 重置选中项当列表变化
|
||||
useEffect(() => { setSelected(0) }, [items.length])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); setSelected(s => Math.min(s + 1, items.length - 1)) }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); setSelected(s => Math.max(s - 1, 0)) }
|
||||
if (e.key === 'Enter' && items[selected]) { items[selected].action(); onClose() }
|
||||
}
|
||||
document.addEventListener('keydown', handler)
|
||||
return () => document.removeEventListener('keydown', handler)
|
||||
}, [open, items, selected, onClose])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
// 按分组聚合
|
||||
const grouped: Record<string, CommandItem[]> = {}
|
||||
for (const item of items) {
|
||||
grouped[item.group] = grouped[item.group] || []
|
||||
grouped[item.group].push(item)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-start justify-center bg-ink-900/50 p-4 pt-[15vh]"
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="命令面板"
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-lg border border-ink-900 bg-paper-50 shadow-2xl"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* 搜索框 */}
|
||||
<div className="flex items-center gap-2 border-b border-ink-200 px-3 py-2.5">
|
||||
<span className="text-ink-400">⌘</span>
|
||||
<input
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder="输入页面名或路径…"
|
||||
autoFocus
|
||||
className="flex-1 bg-transparent text-sm outline-none placeholder:text-ink-400"
|
||||
/>
|
||||
<kbd className="border border-ink-200 px-1.5 py-0.5 text-2xs text-ink-400">ESC</kbd>
|
||||
</div>
|
||||
|
||||
{/* 结果列表 */}
|
||||
<div className="max-h-[50vh] overflow-y-auto py-2">
|
||||
{items.length === 0 ? (
|
||||
<p className="py-6 text-center text-xs text-ink-400">无匹配页面</p>
|
||||
) : (
|
||||
Object.entries(grouped).map(([group, groupItems]) => (
|
||||
<div key={group}>
|
||||
<p className="px-3 py-1 text-2xs font-medium uppercase tracking-widest text-ink-400">{group}</p>
|
||||
{groupItems.map((item, i) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => { item.action(); onClose() }}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors ${
|
||||
selected === items.indexOf(item) ? 'bg-paper-100 text-press' : 'text-ink-700 hover:bg-paper-100'
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">{item.label}</span>
|
||||
<span className="ml-auto text-2xs text-ink-400">{item.id}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部提示 */}
|
||||
<div className="flex items-center gap-3 border-t border-ink-200 px-3 py-1.5 text-2xs text-ink-400">
|
||||
<span>↑↓ 导航</span>
|
||||
<span>↵ 跳转</span>
|
||||
<span>ESC 关闭</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -30,16 +30,16 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
return this.props.fallback
|
||||
}
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 p-6">
|
||||
<div className="bg-white rounded-lg border border-red-200 p-6 max-w-md text-center space-y-3">
|
||||
<div className="text-4xl">⚠️</div>
|
||||
<h2 className="text-lg font-semibold text-gray-800">页面出现错误</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
<div className="flex min-h-screen items-center justify-center bg-paper-50 p-6">
|
||||
<div className="max-w-md space-y-3 border border-ink-900 bg-paper-50 p-6 text-center">
|
||||
<p className="text-2xs tracking-[0.3em] text-ink-400">EXCEPTION</p>
|
||||
<h2 className="font-serif text-lg font-bold text-ink-900">页面出现错误</h2>
|
||||
<p className="text-sm leading-relaxed text-ink-500">
|
||||
{this.state.error?.message || '未知错误'}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => this.setState({ hasError: false, error: null })}
|
||||
className="bg-blue-600 text-white text-sm px-4 py-2 rounded hover:bg-blue-700"
|
||||
className="btn btn-sm"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* 主客队标志:报刊风小方框字。
|
||||
*
|
||||
* 主队 = 反白实心块,客队 = 细线框,与整版纸色语言一致。
|
||||
* 用法: <TeamSideTag side="home" /> 队名
|
||||
*/
|
||||
|
||||
export default function TeamSideTag({ side }: { side: 'home' | 'away' }) {
|
||||
return (
|
||||
<span
|
||||
aria-label={side === 'home' ? '主队' : '客队'}
|
||||
className={`inline-block flex-shrink-0 border px-1 text-center font-sans text-2xs leading-4 ${
|
||||
side === 'home'
|
||||
? 'border-ink-900 bg-ink-900 text-paper-50'
|
||||
: 'border-ink-300 text-ink-400'
|
||||
}`}
|
||||
>
|
||||
{side === 'home' ? '主' : '客'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** 纯文本场景(<option>、字符串拼接)的主客队前缀 */
|
||||
export function teamSidePrefix(side: 'home' | 'away'): string {
|
||||
return side === 'home' ? '[主] ' : '[客] '
|
||||
}
|
||||
+82
-4
@@ -2,6 +2,35 @@
|
||||
@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 {
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
@@ -58,18 +87,47 @@
|
||||
|
||||
/* ── 按钮:方正边框式,悬停反白 ── */
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center gap-1.5 border border-ink-300 bg-transparent px-3 py-1.5
|
||||
@apply inline-flex items-center justify-center gap-1.5 border border-ink-300 bg-transparent px-3
|
||||
text-sm text-ink-700 transition-colors duration-150
|
||||
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;
|
||||
disabled:hover:border-ink-300 disabled:hover:bg-transparent disabled:hover:text-ink-700
|
||||
min-h-[44px] py-1.5;
|
||||
}
|
||||
.btn-sm {
|
||||
@apply px-2.5 py-1 text-xs;
|
||||
@apply px-2.5 text-xs min-h-[36px];
|
||||
}
|
||||
.btn-solid {
|
||||
@apply border-ink-900 bg-ink-900 text-paper-50 hover:border-press hover:bg-press;
|
||||
}
|
||||
.btn-danger {
|
||||
@apply border-press bg-transparent text-press hover:bg-press hover:text-paper-50;
|
||||
}
|
||||
|
||||
/* ── 统一空态 ── */
|
||||
.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 {
|
||||
@@ -80,7 +138,7 @@
|
||||
|
||||
/* ── 版面切换文字标签(联赛/状态/模式) ── */
|
||||
.tab {
|
||||
@apply whitespace-nowrap px-0.5 py-1 text-sm text-ink-500 transition-colors hover:text-ink-900;
|
||||
@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;
|
||||
@@ -99,4 +157,24 @@
|
||||
.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 后台 ──
|
||||
与前台共用同一套报刊风组件(.btn/.field/.tab/.section-head/.skeleton),
|
||||
不再单独维护暗色主题。 */
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* 共享 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' }),
|
||||
}
|
||||
+215
-616
@@ -1,308 +1,100 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
interface Match {
|
||||
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
|
||||
}
|
||||
|
||||
interface Prediction {
|
||||
prediction_id: number
|
||||
provider: string
|
||||
model: string
|
||||
prompt_version: string | null
|
||||
mode: string
|
||||
pred_home_goals: number | null
|
||||
pred_away_goals: number | null
|
||||
pred_1x2: string | null
|
||||
subjective_confidence: number | null
|
||||
reasoning: string | null
|
||||
agent_outputs: AgentReport[] | null
|
||||
agent_weights: Record<string, number> | null
|
||||
context: string
|
||||
latency_ms: number | null
|
||||
}
|
||||
|
||||
interface AgentReport {
|
||||
agent: string
|
||||
status: string
|
||||
data_sufficiency: string
|
||||
analysis: string
|
||||
home_edge: number | null
|
||||
subjective_confidence: number | null
|
||||
key_evidence: string[]
|
||||
exp_home_goals: number | null
|
||||
exp_away_goals: number | null
|
||||
probable_score: string | null
|
||||
model: string
|
||||
latency_ms: number | null
|
||||
}
|
||||
|
||||
const AGENT_LABELS: Record<string, string> = {
|
||||
h2h: '历史交锋',
|
||||
form: '近期状态',
|
||||
stats: '攻防数据',
|
||||
home_away: '主客因素',
|
||||
injuries: '阵容完整性',
|
||||
}
|
||||
|
||||
const LEAGUES = [
|
||||
{ code: 'E0', name: '英超' },
|
||||
{ code: 'SP1', name: '西甲' },
|
||||
{ code: 'D1', name: '德甲' },
|
||||
{ code: 'I1', name: '意甲' },
|
||||
{ code: 'F1', name: '法甲' },
|
||||
]
|
||||
|
||||
/** 汉字编号,给专家意见排版用 */
|
||||
const CN_NUM = ['一', '二', '三', '四', '五', '六', '七', '八']
|
||||
|
||||
const STATUS_META: Record<string, { label: string; cls: string }> = {
|
||||
finished: { label: '已完赛', cls: 'text-ink-400' },
|
||||
scheduled: { label: '未开赛', cls: 'text-ink-600' },
|
||||
live: { label: '进行中', cls: 'text-press font-medium' },
|
||||
}
|
||||
|
||||
/** 1x2 → 中文标签 */
|
||||
const OUTCOME_LABEL: Record<string, string> = { '1': '主胜', X: '平局', '2': '客胜' }
|
||||
|
||||
/** 置信度细线:0~1 数值的低调可视化 */
|
||||
function Meter({ value }: { value: number }) {
|
||||
const pct = Math.max(0, Math.min(100, Math.round(value * 100)))
|
||||
return (
|
||||
<div className="h-px w-full bg-ink-200" role="presentation">
|
||||
<div className="h-px bg-press transition-[width] duration-500" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** home_edge(-1~1,正=利主队)的可视化:以中线为原点的双向细条 */
|
||||
function EdgeBar({ value }: { value: number }) {
|
||||
const v = Math.max(-1, Math.min(1, value))
|
||||
const half = Math.abs(v) * 50
|
||||
return (
|
||||
<div className="relative h-px w-full bg-ink-200" role="presentation">
|
||||
<span className="absolute left-1/2 top-1/2 h-2 w-px -translate-x-1/2 -translate-y-1/2 bg-ink-400" />
|
||||
<span
|
||||
className={`absolute top-0 h-px transition-all duration-500 ${v >= 0 ? 'bg-press' : 'bg-ink-600'}`}
|
||||
style={
|
||||
v >= 0
|
||||
? { left: '50%', width: `${half}%` }
|
||||
: { right: '50%', width: `${half}%` }
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 骨架占位行:低调脉动灰块 */
|
||||
function SkeletonRows({ n = 4 }: { n?: number }) {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: n }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4 border-b border-ink-200 px-1 py-3.5">
|
||||
<div className="skeleton h-3 w-16" />
|
||||
<div className="skeleton h-3 flex-1" />
|
||||
<div className="skeleton h-3 w-10" />
|
||||
<div className="skeleton h-3 flex-1" />
|
||||
<div className="skeleton h-3 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Spinner({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 20 20"
|
||||
className={`h-3.5 w-3.5 animate-spin ${className}`}
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="10" cy="10" r="7.5" stroke="currentColor" strokeWidth="1.5" strokeOpacity="0.25" />
|
||||
<path d="M17.5 10A7.5 7.5 0 0010 2.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** 胜平负一行文字:选中的红字加方块标记,未选中的退灰 */
|
||||
function OutcomeLine({
|
||||
pick,
|
||||
confidence,
|
||||
}: {
|
||||
pick: string | null
|
||||
confidence: number | null
|
||||
}) {
|
||||
const options = ['1', 'X', '2'] as const
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-baseline justify-center gap-6 sm:gap-10">
|
||||
{options.map(o => {
|
||||
const on = pick === o
|
||||
return (
|
||||
<div key={o} className="flex flex-col items-center gap-1">
|
||||
<span className={`flex items-center gap-1.5 text-sm ${on ? 'font-semibold text-press' : 'text-ink-400'}`}>
|
||||
{on && <span className="inline-block h-2 w-2 bg-press" aria-hidden="true" />}
|
||||
{OUTCOME_LABEL[o]}
|
||||
</span>
|
||||
{on && confidence !== null && (
|
||||
<span className="text-2xs tabular-nums text-ink-500">
|
||||
置信 {Math.round(confidence * 100)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{pick && confidence !== null && (
|
||||
<div className="mx-auto mt-3 max-w-xs">
|
||||
<Meter value={confidence} />
|
||||
<p className="mt-1 text-center text-2xs text-ink-400">主观置信度,非统计概率</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/**
|
||||
* 赛程页(组装层)。
|
||||
*
|
||||
* D3(工程债): 原文件 ~1400 行,已拆分为:
|
||||
* matches/types.ts — 类型与常量
|
||||
* matches/ui.tsx — 原子 UI 小件(Spinner/Skeleton/Switch/日期工具)
|
||||
* matches/hooks/useMatchesList.ts — 列表/分页/进行中比赛加载
|
||||
* matches/hooks/useMatchPredict.ts — 预测流程状态机
|
||||
* matches/components/MatchPredictPanel.tsx — 预测弹窗全套
|
||||
* matches/components/MatchDetailSection.tsx — 赛程行 + 展开详情
|
||||
* 本文件只负责状态装配与版面组织,不含数据获取与展示细节。
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { fetchMatchDetail, fetchMatchContext } from '../admin/dal'
|
||||
import type { MatchDetailOut, MatchContextOut } from '../admin/types'
|
||||
import { useMatchesList } from './matches/hooks/useMatchesList'
|
||||
import { useMatchPredict } from './matches/hooks/useMatchPredict'
|
||||
import { useLeagues } from './matches/hooks/useLeagues'
|
||||
import { PredictModal } from './matches/components/MatchPredictPanel'
|
||||
import { MatchRow } from './matches/components/MatchDetailSection'
|
||||
import { Spinner, SkeletonRows, Switch, formatDateHeader, groupByDate, withinNext3Days } from './matches/ui'
|
||||
import { type Match } from './matches/types'
|
||||
|
||||
export default function Matches() {
|
||||
const [league, setLeague] = useState('E0')
|
||||
const [status, setStatus] = useState('scheduled')
|
||||
const [matches, setMatches] = useState<Match[]>([])
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [predictingId, setPredictingId] = useState<number | null>(null)
|
||||
const [prediction, setPrediction] = useState<Prediction | null>(null)
|
||||
const [predictionFor, setPredictionFor] = useState<Match | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [mode, setMode] = useState<'single' | 'multi'>('multi')
|
||||
const [error, setError] = useState<string | null>(null) // 列表与预测共用(拆分前即如此)
|
||||
// P1-3: 联赛列表优先请求 /api/v1/leagues,失败/空回退本地五大联赛常量
|
||||
const leagues = useLeagues()
|
||||
const {
|
||||
league, setLeague,
|
||||
status, setStatus,
|
||||
matches,
|
||||
nextCursor,
|
||||
loading,
|
||||
loadingMore,
|
||||
showAllUpcoming, setShowAllUpcoming,
|
||||
liveMatches,
|
||||
load,
|
||||
loadMore,
|
||||
} = useMatchesList({ onError: setError })
|
||||
|
||||
// 请求竞态防护:切换联赛/状态很快时,先发的慢请求可能后返回,
|
||||
// 把旧结果覆盖到新筛选上。用递增序号只认最后一次请求的响应。
|
||||
const loadSeq = useRef(0)
|
||||
const predictSeq = useRef(0)
|
||||
const {
|
||||
predictingId,
|
||||
prediction,
|
||||
predictionFor,
|
||||
predict,
|
||||
closePredict,
|
||||
} = useMatchPredict({ onError: setError })
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const seq = ++loadSeq.current
|
||||
setLoading(true)
|
||||
// 切换筛选时作废进行中的「加载更多」,避免其标志位卡住
|
||||
setLoadingMore(false)
|
||||
setError(null)
|
||||
try {
|
||||
const params = new URLSearchParams({ league, status, limit: '50' })
|
||||
const res = await fetch(`/api/v1/matches?${params}`)
|
||||
if (seq !== loadSeq.current) return // 已有更新的请求,丢弃本次结果
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json()
|
||||
if (seq !== loadSeq.current) return
|
||||
setMatches(data.items)
|
||||
setNextCursor(data.next_cursor ?? null)
|
||||
} catch (e) {
|
||||
if (seq !== loadSeq.current) return
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
if (seq === loadSeq.current) setLoading(false)
|
||||
}
|
||||
}, [league, status])
|
||||
// ── 详情展开(懒加载,只读,不触发 LLM) ──
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null)
|
||||
const [detailMap, setDetailMap] = useState<Record<number, MatchDetailOut>>({})
|
||||
const [contextMap, setContextMap] = useState<Record<number, MatchContextOut>>({})
|
||||
const [detailLoading, setDetailLoading] = useState<number | null>(null)
|
||||
|
||||
// 加载下一页(游标分页)
|
||||
const loadMore = async () => {
|
||||
if (!nextCursor || loadingMore) return
|
||||
const seq = loadSeq.current // 不做自增:切换筛选会自增,这里只跟随当前序列
|
||||
setLoadingMore(true)
|
||||
try {
|
||||
const params = new URLSearchParams({ league, status, limit: '50', cursor: nextCursor })
|
||||
const res = await fetch(`/api/v1/matches?${params}`)
|
||||
if (seq !== loadSeq.current) return
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json()
|
||||
if (seq !== loadSeq.current) return
|
||||
setMatches(prev => [...prev, ...data.items])
|
||||
setNextCursor(data.next_cursor ?? null)
|
||||
} catch (e) {
|
||||
if (seq !== loadSeq.current) return
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
if (seq === loadSeq.current) setLoadingMore(false)
|
||||
}
|
||||
}
|
||||
// 监听滚动,超过 300px 显示回到顶部按钮
|
||||
const [showBackTop, setShowBackTop] = useState(false)
|
||||
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' })
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
const leagueName = leagues.find(l => l.code === league)?.name ?? league
|
||||
|
||||
const predict = async (m: Match) => {
|
||||
const seq = ++predictSeq.current
|
||||
setPredictingId(m.id)
|
||||
setError(null)
|
||||
setPrediction(null)
|
||||
setPredictionFor(m)
|
||||
try {
|
||||
const res = await fetch('/api/v1/predict', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ match_id: m.id, mode }),
|
||||
})
|
||||
if (seq !== predictSeq.current) return
|
||||
if (!res.ok) {
|
||||
const t = await res.text()
|
||||
throw new Error(`HTTP ${res.status}: ${t}`)
|
||||
/** 未开赛默认仅展示未来 3 天;其余状态展示全部。showAllUpcoming=true 时展开全部。 */
|
||||
const isScheduledView = status === 'scheduled'
|
||||
const visibleMatches = (!isScheduledView || showAllUpcoming)
|
||||
? matches
|
||||
: matches.filter(m => withinNext3Days(m.match_date))
|
||||
// 是否有被折叠的未开赛比赛(用于显示「展开」按钮)
|
||||
const hasHiddenUpcoming = isScheduledView && !showAllUpcoming && matches.length > visibleMatches.length
|
||||
|
||||
/** 展开时懒加载详情: 缓存命中则不再请求 */
|
||||
async function toggleExpand(m: Match) {
|
||||
if (expandedId === m.id) { setExpandedId(null); return }
|
||||
setExpandedId(m.id)
|
||||
if (!detailMap[m.id] || !contextMap[m.id]) {
|
||||
setDetailLoading(m.id)
|
||||
try {
|
||||
const [d, c] = await Promise.all([
|
||||
fetchMatchDetail(m.id).catch(() => null),
|
||||
fetchMatchContext(m.id).catch(() => null),
|
||||
])
|
||||
if (d) setDetailMap(prev => ({ ...prev, [m.id]: d }))
|
||||
if (c) setContextMap(prev => ({ ...prev, [m.id]: c }))
|
||||
} finally {
|
||||
setDetailLoading(null)
|
||||
}
|
||||
const data = await res.json()
|
||||
if (seq !== predictSeq.current) return
|
||||
setPrediction(data)
|
||||
} catch (e) {
|
||||
if (seq !== predictSeq.current) return
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
if (seq === predictSeq.current) setPredictingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const fmtDate = (s: string) => {
|
||||
const d = new Date(s)
|
||||
return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
const leagueName = LEAGUES.find(l => l.code === league)?.name ?? league
|
||||
|
||||
/** 状态/模式一组的文字切换 */
|
||||
const Switch = ({ value, onChange, items }: {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
items: { v: string; label: string; title?: string }[]
|
||||
}) => (
|
||||
<span className="inline-flex items-center gap-2.5">
|
||||
{items.map((it, i) => (
|
||||
<span key={it.v} className="inline-flex items-center gap-2.5">
|
||||
{i > 0 && <span className="text-ink-300" aria-hidden="true">/</span>}
|
||||
<button
|
||||
onClick={() => onChange(it.v)}
|
||||
title={it.title}
|
||||
className={`relative tab ${value === it.v ? 'tab-on' : ''} text-xs`}
|
||||
>
|
||||
{it.label}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* ── 联赛版面切换 ── */}
|
||||
<nav className="flex items-center gap-6 overflow-x-auto border-b border-ink-900" aria-label="联赛">
|
||||
{LEAGUES.map(l => (
|
||||
{leagues.map(l => (
|
||||
<button
|
||||
key={l.code}
|
||||
onClick={() => setLeague(l.code)}
|
||||
@@ -313,9 +105,9 @@ export default function Matches() {
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* ── 第二行:状态 / 模式 / 计数 / 刷新 ── */}
|
||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 text-xs text-ink-500">
|
||||
<span className="inline-flex items-center gap-2.5">
|
||||
{/* ── 第二行:状态 / 模式 / 日期 / 计数 / 刷新(小屏 flex-wrap) ── */}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-ink-500 sm:gap-x-5">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="text-2xs text-ink-400">状态</span>
|
||||
<Switch
|
||||
value={status}
|
||||
@@ -328,356 +120,163 @@ export default function Matches() {
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span className="inline-flex items-center gap-2.5">
|
||||
<span className="text-2xs text-ink-400">模式</span>
|
||||
<Switch
|
||||
value={mode}
|
||||
onChange={v => setMode(v as 'single' | 'multi')}
|
||||
items={[
|
||||
{ v: 'single', label: '单次', title: '单次调用,快但只有一个模型看全部数据' },
|
||||
{ v: 'multi', label: '多专家', title: '5 个专家并行分析后由终裁汇总,质量更高' },
|
||||
]}
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span className="ml-auto inline-flex items-center gap-3">
|
||||
<span className="tabular-nums">共 {matches.length} 场</span>
|
||||
<span className="tabular-nums">
|
||||
{isScheduledView && !showAllUpcoming && hasHiddenUpcoming
|
||||
? `未来3天 ${visibleMatches.length} / 共 ${matches.length} 场`
|
||||
: `共 ${visibleMatches.length} 场`}
|
||||
</span>
|
||||
<button onClick={load} disabled={loading} className="btn btn-sm">
|
||||
{loading ? (<><Spinner /> 获取中</>) : '刷新'}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ── 错误提示 ── */}
|
||||
{/* ── 错误提示(统一 error-banner 样式) ── */}
|
||||
{error && (
|
||||
<div className="flex items-start justify-between gap-3 border border-press bg-press-wash px-4 py-3">
|
||||
<div className="error-banner">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-press">请求失败</p>
|
||||
<p className="mt-0.5 text-xs text-ink-600">{error}</p>
|
||||
<p className="error-banner-title">请求失败</p>
|
||||
<p className="error-banner-detail">{error}</p>
|
||||
</div>
|
||||
<button onClick={() => setError(null)} 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>
|
||||
<button onClick={() => setError(null)} className="text-ink-400 transition-colors hover:text-ink-900 text-lg leading-none p-1" aria-label="关闭">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 赛程栏:表格化,行间细线 ── */}
|
||||
{/* ── 预测弹窗:进行中可视化 / 结果面板 ── */}
|
||||
{predictionFor && (
|
||||
<PredictModal
|
||||
match={predictionFor}
|
||||
predicting={predictingId === predictionFor.id}
|
||||
prediction={predictingId === predictionFor.id ? null : prediction}
|
||||
error={predictingId === predictionFor.id ? null : error}
|
||||
onClose={closePredict}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── 进行中比赛(顶部独立区块,仅未开赛视图展示) ── */}
|
||||
{isScheduledView && liveMatches.length > 0 && (
|
||||
<section aria-label="进行中" className="border border-ink-900 bg-paper-100">
|
||||
<div className="flex items-center gap-2 border-b border-ink-900 px-3 py-2">
|
||||
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-press" />
|
||||
<span className="text-xs font-medium tracking-wide text-ink-700">进行中 · 实时比分</span>
|
||||
<span className="text-2xs text-ink-400">{liveMatches.length} 场</span>
|
||||
</div>
|
||||
<div className="divide-y divide-ink-200">
|
||||
{liveMatches.map(m => {
|
||||
const homeName = m.home_team_zh || m.home_team
|
||||
const awayName = m.away_team_zh || m.away_team
|
||||
return (
|
||||
<div key={m.id} className="flex items-center justify-between gap-3 px-3 py-2.5">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="w-12 shrink-0 text-center font-serif text-lg font-bold tabular-nums text-ink-900">
|
||||
{m.home_goals ?? '-'}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-xs text-ink-700">{homeName}</span>
|
||||
</div>
|
||||
<span className="shrink-0 text-2xs text-ink-400">vs</span>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="min-w-0 truncate text-right text-xs text-ink-700">{awayName}</span>
|
||||
<span className="w-12 shrink-0 text-center font-serif text-lg font-bold tabular-nums text-ink-900">
|
||||
{m.away_goals ?? '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── 赛程栏:表格化,行间细线,按日期分组 ── */}
|
||||
<section aria-label="赛程">
|
||||
{loading && <SkeletonRows n={4} />}
|
||||
|
||||
{!loading && matches.length === 0 && (
|
||||
<div className="border-y border-ink-200 py-14 text-center">
|
||||
<p className="font-serif text-sm text-ink-600">本版暂无赛程</p>
|
||||
<p className="mt-1.5 text-xs text-ink-400">请先通过采集接口导入 {leagueName} 的比赛数据</p>
|
||||
{!loading && visibleMatches.length === 0 && (
|
||||
<div className="empty-state">
|
||||
{matches.length > 0 ? (
|
||||
<>
|
||||
<p className="empty-state-title">未来 3 天暂无 {leagueName} 比赛</p>
|
||||
<p className="empty-state-sub">已导入 {matches.length} 场未开赛,点击下方按钮查看</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="empty-state-title">本版暂无赛程</p>
|
||||
<p className="empty-state-sub">请先通过「数据采集」导入 {leagueName} 的比赛数据</p>
|
||||
<a href="/admin/collection" className="empty-state-action">
|
||||
前往数据采集 <span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && matches.map(m => {
|
||||
const st = STATUS_META[m.match_status] ?? { label: m.match_status, cls: 'text-ink-400' }
|
||||
const homeName = m.home_team_zh || m.home_team
|
||||
const awayName = m.away_team_zh || m.away_team
|
||||
const busy = predictingId === m.id
|
||||
const active = predictionFor?.id === m.id
|
||||
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
className={`border-b border-ink-200 px-1 py-3 transition-colors hover:bg-paper-100 ${
|
||||
active ? 'bg-press-wash/50' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col gap-2 sm:grid sm:grid-cols-[88px_minmax(0,1fr)_64px_minmax(0,1fr)_56px_auto] sm:items-center sm:gap-x-3 sm:gap-y-0">
|
||||
{/* 日期 + 状态:移动端同行,桌面端日期单独归列 */}
|
||||
<div className="flex items-center justify-between sm:contents">
|
||||
<span className="text-2xs tabular-nums text-ink-400">{fmtDate(m.match_date)}</span>
|
||||
<span className={`text-2xs sm:hidden ${st.cls}`}>{st.label}</span>
|
||||
</div>
|
||||
|
||||
{/* 对阵:移动端主队/比分/客队同一行,桌面端 sm:contents 拆回 grid 列 */}
|
||||
<div className="flex items-center gap-2 sm:contents">
|
||||
{/* 主队(右对齐) */}
|
||||
<div className="flex min-w-0 flex-1 items-center justify-end">
|
||||
<span className="truncate text-sm font-medium text-ink-900">{homeName}</span>
|
||||
</div>
|
||||
|
||||
{/* 比分 / VS */}
|
||||
<div className="flex w-14 flex-shrink-0 flex-col items-center sm:w-auto">
|
||||
{m.home_goals !== null && m.away_goals !== null ? (
|
||||
<span className="font-serif text-base font-bold tabular-nums text-ink-900">
|
||||
{m.home_goals}<span className="mx-0.5 font-normal text-ink-300">:</span>{m.away_goals}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-2xs tracking-widest text-ink-400">VS</span>
|
||||
)}
|
||||
{m.home_xg !== null && m.away_xg !== null && (
|
||||
<span className="text-2xs tabular-nums text-ink-400">
|
||||
xG {m.home_xg.toFixed(1)}-{m.away_xg.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 客队(左对齐) */}
|
||||
<div className="flex min-w-0 flex-1 items-center">
|
||||
<span className="truncate text-sm font-medium text-ink-900">{awayName}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 状态列(桌面) */}
|
||||
<span className={`hidden text-right text-2xs sm:block ${st.cls}`}>{st.label}</span>
|
||||
|
||||
{/* 预测按钮 */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => predict(m)}
|
||||
disabled={busy}
|
||||
className="btn btn-sm w-[76px]"
|
||||
title={`以${mode === 'multi' ? '多专家' : '单次'}模式预测这场`}
|
||||
>
|
||||
{busy ? (<><Spinner /> 预测中</>) : '预测'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{!loading && groupByDate(visibleMatches).map(([dateKey, group]) => (
|
||||
<div key={dateKey}>
|
||||
{/* 日期分组头:sticky 但 z 低于弹窗 z-50 */}
|
||||
<div className="sticky top-0 z-20 border-b border-ink-900 bg-paper-100 px-3 py-2 text-xs font-medium tracking-wide text-ink-600">
|
||||
{formatDateHeader(dateKey)}
|
||||
<span className="ml-2 text-2xs font-normal text-ink-400">{group.length} 场</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{group.map(m => (
|
||||
<MatchRow
|
||||
key={m.id}
|
||||
m={m}
|
||||
busy={predictingId === m.id}
|
||||
expanded={expandedId === m.id}
|
||||
detail={detailMap[m.id]}
|
||||
ctx={contextMap[m.id]}
|
||||
detailLoading={detailLoading === m.id}
|
||||
onToggle={() => toggleExpand(m)}
|
||||
onPredict={predict}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!loading && nextCursor && (
|
||||
{/* 单一「加载更多」按钮:3 天视图时先展开,展开后从服务器拉取下一页 */}
|
||||
{!loading && (hasHiddenUpcoming || nextCursor) && (
|
||||
<div className="flex justify-center pt-4">
|
||||
<button onClick={loadMore} disabled={loadingMore} className="btn btn-sm">
|
||||
{loadingMore ? (<><Spinner /> 获取中</>) : '载入更多'}
|
||||
{hasHiddenUpcoming ? (
|
||||
<button
|
||||
onClick={() => setShowAllUpcoming(true)}
|
||||
className="btn btn-outline min-h-[44px] w-full max-w-xs sm:w-auto"
|
||||
>
|
||||
显示后续 {matches.length - visibleMatches.length} 场未开赛
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={loadMore} disabled={loadingMore} className="btn min-h-[44px] w-full max-w-xs sm:w-auto">
|
||||
{loadingMore ? (<><Spinner /> 获取中</>) : '载入更多赛程'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 已展开全部但未开赛:提供「收起」回未来 3 天 */}
|
||||
{!loading && isScheduledView && showAllUpcoming && matches.length > 0 && (
|
||||
<div className="flex justify-center pt-2">
|
||||
<button
|
||||
onClick={() => setShowAllUpcoming(false)}
|
||||
className="text-xs text-ink-400 hover:text-ink-700 transition-colors"
|
||||
>
|
||||
收起,仅显示未来 3 天
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── 预测中占位 ── */}
|
||||
{predictingId && !prediction && (
|
||||
<div className="border border-ink-900">
|
||||
<div className="flex items-center gap-2 border-b border-ink-900 bg-paper-100 px-4 py-2.5">
|
||||
<Spinner className="text-press" />
|
||||
<span className="text-sm font-medium text-ink-800">正在生成预测</span>
|
||||
<span className="text-2xs text-ink-500">
|
||||
{mode === 'multi' ? '五路专家并行分析后终裁,约需 20-60 秒' : '单次调用,约需 5-15 秒'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-4 px-4 py-6">
|
||||
<div className="flex items-center justify-center gap-6">
|
||||
<div className="skeleton h-4 w-20" />
|
||||
<div className="skeleton h-10 w-24" />
|
||||
<div className="skeleton h-4 w-20" />
|
||||
</div>
|
||||
<div className="skeleton mx-auto h-px w-64" />
|
||||
<div className="skeleton h-16 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 预测版 ── */}
|
||||
{prediction && predictionFor && (
|
||||
<PredictionPanel prediction={prediction} match={predictionFor} mode={mode} />
|
||||
)}
|
||||
{/* 回到顶部按钮 */}
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
function PredictionPanel({
|
||||
prediction,
|
||||
match,
|
||||
mode,
|
||||
}: {
|
||||
prediction: Prediction
|
||||
match: Match
|
||||
mode: 'single' | 'multi'
|
||||
}) {
|
||||
const homeName = match.home_team_zh || match.home_team
|
||||
const awayName = match.away_team_zh || match.away_team
|
||||
const okReports = (prediction.agent_outputs ?? []).filter(r => r.status === 'ok')
|
||||
|
||||
return (
|
||||
<article className="border border-ink-900 bg-paper-50">
|
||||
{/* 版头 */}
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2 border-b border-ink-900 bg-paper-100 px-4 py-2.5 sm:px-5">
|
||||
<h3 className="font-serif text-sm font-bold text-ink-900">
|
||||
预测版 · {homeName} 对 {awayName}
|
||||
</h3>
|
||||
<span className="text-2xs tabular-nums text-ink-500">
|
||||
{prediction.provider} / {prediction.model}
|
||||
{prediction.latency_ms !== null && ` · ${(prediction.latency_ms / 1000).toFixed(1)}s`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-7 px-4 py-6 sm:px-5">
|
||||
{/* ── 预测比分:版面核心,大号宋体 ── */}
|
||||
<div className="text-center">
|
||||
<p className="font-serif text-5xl font-bold tabular-nums leading-none text-ink-900 sm:text-6xl">
|
||||
{prediction.pred_home_goals ?? '-'}
|
||||
<span className="mx-3 font-normal text-ink-300">:</span>
|
||||
{prediction.pred_away_goals ?? '-'}
|
||||
</p>
|
||||
<p className="mt-3 text-2xs tracking-[0.5em] text-ink-400">预测比分</p>
|
||||
</div>
|
||||
|
||||
{/* ── 胜平负 ── */}
|
||||
<div className="border-y border-ink-200 py-4">
|
||||
<OutcomeLine pick={prediction.pred_1x2} confidence={prediction.subjective_confidence} />
|
||||
</div>
|
||||
|
||||
{/* ── 元信息一行 ── */}
|
||||
<p className="text-center text-2xs text-ink-500">
|
||||
{mode === 'multi' ? `多专家模式 · ${okReports.length}/${prediction.agent_outputs?.length ?? 0} 路有效` : '单次模式'}
|
||||
{prediction.prompt_version && ` · prompt ${prediction.prompt_version}`}
|
||||
{prediction.latency_ms !== null && ` · 终裁耗时 ${(prediction.latency_ms / 1000).toFixed(1)} 秒`}
|
||||
</p>
|
||||
|
||||
{/* ── 专家意见 ── */}
|
||||
{prediction.agent_outputs && prediction.agent_outputs.length > 0 && (
|
||||
<section>
|
||||
<div className="section-head flex flex-wrap items-baseline justify-between gap-1">
|
||||
<span>五路专家意见</span>
|
||||
{prediction.agent_weights && (
|
||||
<span className="font-sans text-2xs font-normal text-ink-500">
|
||||
终裁权重:{Object.entries(prediction.agent_weights)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([k, v]) => `${AGENT_LABELS[k] ?? k} ${Math.round(v * 100)}%`)
|
||||
.join(' / ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{prediction.agent_outputs.map((r, i) => (
|
||||
<AgentCard key={r.agent} report={r} no={CN_NUM[i] ?? String(i + 1)} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── 终裁意见:引文式,红竖线 ── */}
|
||||
{prediction.reasoning && (
|
||||
<section>
|
||||
<h4 className="section-head mb-3">终裁意见</h4>
|
||||
<blockquote className="border-l-2 border-press pl-4">
|
||||
<p className="whitespace-pre-wrap font-serif text-sm leading-loose text-ink-700">
|
||||
{prediction.reasoning}
|
||||
</p>
|
||||
</blockquote>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── 原始上下文 ── */}
|
||||
<details className="group">
|
||||
<summary className="flex cursor-pointer list-none items-center gap-1.5 text-xs text-ink-500 transition-colors hover:text-ink-800">
|
||||
<svg viewBox="0 0 20 20" className="h-3 w-3 transition-transform group-open:rotate-90" fill="currentColor" aria-hidden="true">
|
||||
<path d="M7.3 5.3a1 1 0 011.4 0l4 4a1 1 0 010 1.4l-4 4a1 1 0 01-1.4-1.4L10.6 10 7.3 6.7a1 1 0 010-1.4z" />
|
||||
</svg>
|
||||
查看喂给模型的完整数据切片
|
||||
</summary>
|
||||
<pre className="mt-2 max-h-80 overflow-auto border border-ink-200 bg-paper-100 p-3 font-mono text-2xs leading-relaxed text-ink-600">
|
||||
{prediction.context}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
const STATUS_BADGE: Record<string, { label: string; cls: string }> = {
|
||||
ok: { label: '正常', cls: 'text-ink-500' },
|
||||
no_data: { label: '无数据', cls: 'text-ink-400' },
|
||||
error: { label: '调用失败', cls: 'text-press' },
|
||||
parse_error: { label: '解析失败', cls: 'text-press' },
|
||||
}
|
||||
|
||||
const SUFFICIENCY_LABEL: Record<string, string> = {
|
||||
high: '充分',
|
||||
medium: '一般',
|
||||
low: '偏少',
|
||||
none: '无',
|
||||
}
|
||||
|
||||
/** 单路专家意见:汉字编号 + 细线行 */
|
||||
function AgentCard({ report: r, no }: { report: AgentReport; no: string }) {
|
||||
const badge = STATUS_BADGE[r.status] ?? { label: r.status, cls: 'text-ink-400' }
|
||||
const inactive = r.status !== 'ok'
|
||||
|
||||
return (
|
||||
<details className="group border-b border-ink-200">
|
||||
<summary className="flex cursor-pointer list-none items-baseline gap-2.5 px-1 py-3">
|
||||
<span className="font-serif text-sm text-ink-400">{no}</span>
|
||||
<span className="text-sm font-medium text-ink-900">{AGENT_LABELS[r.agent] ?? r.agent}</span>
|
||||
<span className={`text-2xs ${badge.cls}`}>{badge.label}</span>
|
||||
|
||||
<span className="ml-auto flex items-baseline gap-3 text-2xs tabular-nums text-ink-500">
|
||||
{r.status === 'ok' && r.subjective_confidence !== null && (
|
||||
<span>信心 {Math.round(r.subjective_confidence * 100)}%</span>
|
||||
)}
|
||||
{r.status === 'ok' && r.probable_score && (
|
||||
<span className="font-serif font-bold text-ink-800">{r.probable_score}</span>
|
||||
)}
|
||||
<svg viewBox="0 0 20 20" className="h-3 w-3 self-center text-ink-300 transition-transform group-open:rotate-90" fill="currentColor" aria-hidden="true">
|
||||
<path d="M7.3 5.3a1 1 0 011.4 0l4 4a1 1 0 010 1.4l-4 4a1 1 0 01-1.4-1.4L10.6 10 7.3 6.7a1 1 0 010-1.4z" />
|
||||
</svg>
|
||||
</span>
|
||||
</summary>
|
||||
|
||||
<div className="space-y-3 px-1 pb-4 pl-7">
|
||||
{/* 无数据 / 失败时给出明确说明,避免用户以为是空白 bug */}
|
||||
{inactive && (
|
||||
<p className="text-xs leading-relaxed text-ink-500">
|
||||
{r.status === 'no_data' && '该维度没有可用数据,已跳过 LLM 分析以节省额度(不影响其他专家)。'}
|
||||
{r.status === 'error' && '该专家调用失败,本次结论未纳入其视角(fail-open 设计,不阻断整体预测)。'}
|
||||
{r.status === 'parse_error' && '模型输出未通过格式校验,该报告已丢弃。'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!inactive && r.home_edge !== null && (
|
||||
<div>
|
||||
<div className="mb-1.5 flex items-baseline justify-between text-2xs">
|
||||
<span className="text-ink-500">主队优势</span>
|
||||
<span className={`font-semibold tabular-nums ${r.home_edge > 0 ? 'text-press' : r.home_edge < 0 ? 'text-ink-700' : 'text-ink-500'}`}>
|
||||
{r.home_edge > 0 ? '+' : ''}{r.home_edge.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<EdgeBar value={r.home_edge} />
|
||||
<div className="mt-1 flex justify-between text-2xs text-ink-400">
|
||||
<span>利客队</span>
|
||||
<span>利主队</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{r.analysis && (
|
||||
<p className="font-serif text-sm leading-loose text-ink-700">{r.analysis}</p>
|
||||
)}
|
||||
|
||||
{r.key_evidence.length > 0 && (
|
||||
<ul className="space-y-1.5">
|
||||
{r.key_evidence.map((e, i) => (
|
||||
<li key={i} className="flex gap-2 text-xs leading-relaxed text-ink-600">
|
||||
<span className="flex-shrink-0 text-ink-300" aria-hidden="true">—</span>
|
||||
<span>{e}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{r.exp_home_goals !== null && r.exp_away_goals !== null && (
|
||||
<p className="text-xs text-ink-500">
|
||||
进球期望 <span className="font-serif font-bold tabular-nums text-ink-900">{r.exp_home_goals.toFixed(1)} - {r.exp_away_goals.toFixed(1)}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!inactive && (
|
||||
<p className="border-t border-ink-100 pt-2.5 text-2xs text-ink-400">
|
||||
数据充分度 {SUFFICIENCY_LABEL[r.data_sufficiency] ?? r.data_sufficiency}
|
||||
<span className="mx-2 text-ink-200">|</span>
|
||||
<span className="font-mono">{r.model}</span>
|
||||
{r.latency_ms !== null && <span className="ml-2 tabular-nums">{r.latency_ms}ms</span>}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* 主站 - 联赛积分榜页
|
||||
*
|
||||
* 展示各联赛最新积分榜(位置/积分/净胜/xG差/近期走势/分区),
|
||||
* 数据来自 bzzoiro /leagues/{id}/standings/ 管线采集。
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { fetchStandings } from '../admin/dal'
|
||||
import type { StandingsLeague, StandingRow } from '../admin/dal'
|
||||
import { Spinner } from '../admin/components'
|
||||
|
||||
const LEAGUES = [
|
||||
{ code: 'E0', name: '英超' },
|
||||
{ code: 'SP1', name: '西甲' },
|
||||
{ code: 'D1', name: '德甲' },
|
||||
{ code: 'I1', name: '意甲' },
|
||||
{ code: 'F1', name: '法甲' },
|
||||
{ code: 'CL', name: '欧冠' },
|
||||
{ code: 'EL', name: '欧联' },
|
||||
]
|
||||
|
||||
const ZONE_META: Record<string, { label: string; cls: string }> = {
|
||||
// 欧战资格
|
||||
'Champions League': { label: '欧冠区', cls: 'bg-emerald-100 text-emerald-700' },
|
||||
'Champions League Qualification': { label: '欧冠资格', cls: 'bg-emerald-100 text-emerald-700' },
|
||||
'Europa League': { label: '欧联区', cls: 'bg-amber-100 text-amber-700' },
|
||||
'Conference League': { label: '欧协杯', cls: 'bg-sky-100 text-sky-700' },
|
||||
'Conference League Qualification': { label: '欧协杯', cls: 'bg-sky-100 text-sky-700' },
|
||||
'Europa Conference League': { label: '欧协杯', cls: 'bg-sky-100 text-sky-700' },
|
||||
'Europa Conference League Qualification': { label: '欧协杯', cls: 'bg-sky-100 text-sky-700' },
|
||||
// 升级
|
||||
'Championship': { label: '升级区', cls: 'bg-emerald-100 text-emerald-700' },
|
||||
'Promotion': { label: '升级区', cls: 'bg-emerald-100 text-emerald-700' },
|
||||
'Promotion Group': { label: '升级组', cls: 'bg-emerald-100 text-emerald-700' },
|
||||
// 降级
|
||||
'Relegation': { label: '降级区', cls: 'bg-rose-100 text-rose-700' },
|
||||
'Relegation Playoffs': { label: '降级附加赛', cls: 'bg-orange-100 text-orange-700' },
|
||||
'Relegation Group': { label: '降级组', cls: 'bg-rose-100 text-rose-700' },
|
||||
// 附加赛
|
||||
'Playoffs': { label: '附加赛', cls: 'bg-amber-100 text-amber-700' },
|
||||
'Championship Playoffs': { label: '升级附加赛', cls: 'bg-amber-100 text-amber-700' },
|
||||
'Qualification Playoffs': { label: '资格附加赛', cls: 'bg-sky-100 text-sky-700' },
|
||||
'Qualification': { label: '资格赛', cls: 'bg-sky-100 text-sky-700' },
|
||||
}
|
||||
|
||||
function zoneBadge(zone?: string | null) {
|
||||
if (!zone) return null
|
||||
const meta = ZONE_META[zone] ?? { label: zone, cls: 'bg-ink-100 text-ink-600' }
|
||||
return <span className={`whitespace-nowrap rounded px-1.5 py-0.5 text-2xs font-medium ${meta.cls}`}>{meta.label}</span>
|
||||
}
|
||||
|
||||
/** 近期走势串(W/D/L) → 彩色圆点 */
|
||||
function FormDots({ form }: { form?: string | null }) {
|
||||
if (!form) return <span className="text-2xs text-ink-400">—</span>
|
||||
const colorMap: Record<string, string> = { W: 'bg-emerald-500', D: 'bg-ink-300', L: 'bg-rose-500' }
|
||||
return (
|
||||
<span className="inline-flex gap-0.5">
|
||||
{form.slice(0, 5).split('').map((c, i) => (
|
||||
<span key={i} className={`inline-block h-1.5 w-1.5 rounded-full ${colorMap[c] ?? 'bg-ink-200'}`} />
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function StandingsPage() {
|
||||
const [leagues, setLeagues] = useState<StandingsLeague[]>([])
|
||||
const [activeLeague, setActiveLeague] = useState<string>('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [switching, setSwitching] = useState(false) // 切换联赛中
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showBackTop, setShowBackTop] = useState(false) // 回到顶部按钮显示态
|
||||
|
||||
// 监听滚动,超过 300px 显示回到顶部按钮
|
||||
useEffect(() => {
|
||||
const handleScroll = () => setShowBackTop(window.scrollY > 300)
|
||||
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||
return () => window.removeEventListener('scroll', handleScroll)
|
||||
}, [])
|
||||
|
||||
const scrollToTop = () => window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
|
||||
const load = useCallback(async (code?: string) => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await fetchStandings(code)
|
||||
setLeagues(data.leagues)
|
||||
if (!activeLeague && data.leagues.length > 0) {
|
||||
setActiveLeague(data.leagues[0].league_code)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [activeLeague])
|
||||
|
||||
useEffect(() => { load() }, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 切换联赛(带加载态,禁用 tab 防重复点击)
|
||||
const switchLeague = async (code: string) => {
|
||||
if (code === activeLeague || switching) return
|
||||
setSwitching(true)
|
||||
setActiveLeague(code)
|
||||
try {
|
||||
await fetchStandings(code).then(data => setLeagues(data.leagues))
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败')
|
||||
} finally {
|
||||
setSwitching(false)
|
||||
}
|
||||
}
|
||||
|
||||
const active = leagues.find(l => l.league_code === activeLeague) ?? leagues[0]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 联赛切换 */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{LEAGUES.map(l => (
|
||||
<button
|
||||
key={l.code}
|
||||
onClick={() => switchLeague(l.code)}
|
||||
disabled={switching}
|
||||
className={`rounded border px-3 py-1.5 text-xs transition-colors disabled:opacity-50 ${
|
||||
activeLeague === l.code
|
||||
? 'border-ink-900 bg-ink-900 text-paper-50'
|
||||
: 'border-ink-200 text-ink-500 hover:border-ink-300'
|
||||
}`}
|
||||
>
|
||||
{l.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="border border-rose-300 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="flex justify-center py-12 text-xs text-ink-400">加载中…</div>
|
||||
)}
|
||||
|
||||
{/* 切换联赛时的轻量加载指示 */}
|
||||
{switching && !loading && (
|
||||
<div className="flex items-center gap-2 border-b border-ink-200 px-1 py-2 text-xs text-ink-400">
|
||||
<Spinner /> 切换联赛中…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !active && (
|
||||
<div className="border-y border-ink-200 py-12 text-center">
|
||||
<p className="font-serif text-sm text-ink-600">暂无积分榜数据</p>
|
||||
<p className="mt-1.5 text-xs text-ink-400">
|
||||
请先在管理后台「数据采集」页运行「积分榜」任务。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{active && (
|
||||
<div>
|
||||
<div className="mb-3 flex items-center justify-between border-b border-ink-200 pb-2">
|
||||
<div>
|
||||
<h2 className="font-serif text-lg font-bold text-ink-900">
|
||||
{active.league_name}
|
||||
</h2>
|
||||
<p className="text-xs text-ink-400">
|
||||
{active.season} 赛季 · {active.rows.length} 队
|
||||
{active.retrieved_at && ` · 更新于 ${new Date(active.retrieved_at).toLocaleDateString('zh-CN')}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 积分榜表格 */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[640px] text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-ink-200 text-left text-ink-400">
|
||||
<th className="w-8 py-2 font-medium">#</th>
|
||||
<th className="py-2 font-medium">球队</th>
|
||||
<th className="w-10 text-center py-2 font-medium">赛</th>
|
||||
<th className="w-10 text-center py-2 font-medium">胜</th>
|
||||
<th className="w-10 text-center py-2 font-medium">平</th>
|
||||
<th className="w-10 text-center py-2 font-medium">负</th>
|
||||
<th className="w-12 text-center py-2 font-medium">进/失</th>
|
||||
<th className="w-12 text-center py-2 font-medium">净</th>
|
||||
<th className="w-14 text-center py-2 font-medium">积分</th>
|
||||
<th className="w-16 text-center py-2 font-medium">xG±</th>
|
||||
<th className="w-20 text-center py-2 font-medium">走势</th>
|
||||
<th className="w-16 text-right py-2 font-medium">区</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{active.rows.map((r: StandingRow) => (
|
||||
<tr key={r.position} className="border-b border-ink-100 hover:bg-paper-100">
|
||||
<td className="py-2 font-medium text-ink-700">{r.position}</td>
|
||||
<td className="py-2 font-medium text-ink-900">{r.team}</td>
|
||||
<td className="text-center py-2 text-ink-500">{r.played}</td>
|
||||
<td className="text-center py-2 text-ink-500">{r.won}</td>
|
||||
<td className="text-center py-2 text-ink-500">{r.drawn}</td>
|
||||
<td className="text-center py-2 text-ink-500">{r.lost}</td>
|
||||
<td className="text-center py-2 text-ink-500">{r.goals_for}/{r.goals_against}</td>
|
||||
<td className={`text-center py-2 ${r.goal_diff > 0 ? 'text-emerald-600' : r.goal_diff < 0 ? 'text-rose-600' : 'text-ink-500'}`}>
|
||||
{r.goal_diff > 0 ? `+${r.goal_diff}` : r.goal_diff}
|
||||
</td>
|
||||
<td className="text-center py-2 font-bold text-ink-900">{r.points}</td>
|
||||
<td className="text-center py-2 text-ink-500">
|
||||
{r.xg_for != null && r.xg_against != null
|
||||
? `${(r.xg_for - r.xg_against).toFixed(1)}`
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="py-2"><div className="flex justify-center"><FormDots form={r.form} /></div></td>
|
||||
<td className="py-2 text-right">{zoneBadge(r.zone)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 回到顶部按钮 */}
|
||||
<button
|
||||
onClick={scrollToTop}
|
||||
className={`fixed bottom-6 right-6 z-40 flex h-10 w-10 items-center justify-center rounded-full border border-ink-200 bg-paper-50 text-ink-600 shadow-lg transition-all duration-300 hover:border-ink-400 hover:text-ink-900 ${
|
||||
showBackTop ? 'translate-y-0 opacity-100' : 'translate-y-4 opacity-0 pointer-events-none'
|
||||
}`}
|
||||
aria-label="回到顶部"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* MatchDetailSection: 赛程行(MatchRow)+ 展开详情面板(统计/近况/H2H/历史预测)。
|
||||
*
|
||||
* D3: 从 Matches.tsx 拆出,渲染逻辑原样搬迁。MatchRow 原先是主组件内
|
||||
* group.map 的内联 JSX,现接收回调(onToggle/onPredict)保持行为一致;
|
||||
* 展开懒加载的 state 仍由页面持有。
|
||||
*/
|
||||
import TeamSideTag from '../../../components/TeamSideTag'
|
||||
import { fetchMatchDetail, fetchMatchContext } from '../../../admin/dal'
|
||||
import type { MatchDetailOut, MatchContextOut, MatchRecentPrediction, TeamRecentMatch } from '../../../admin/types'
|
||||
import type { MatchStatsDetail } from '../../../admin/types'
|
||||
import { STATUS_META } from '../types'
|
||||
import type { Match } from '../types'
|
||||
import { Spinner } from '../ui'
|
||||
|
||||
/** 比赛详情面板:双方近况/H2H + 历史预测列表(只读) */
|
||||
function MatchDetailPanel({
|
||||
match, detail, ctx, loading,
|
||||
}: {
|
||||
match: Match
|
||||
detail: MatchDetailOut | undefined
|
||||
ctx: MatchContextOut | undefined
|
||||
loading: boolean
|
||||
}) {
|
||||
const homeName = match.home_team_zh || match.home_team
|
||||
const awayName = match.away_team_zh || match.away_team
|
||||
const finished = match.match_status === 'finished'
|
||||
|
||||
return (
|
||||
<div className="border-b border-ink-200 bg-paper-100/50 px-3 py-4">
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 text-xs text-ink-500"><Spinner /> 加载详情中…</div>
|
||||
)}
|
||||
|
||||
{!loading && !detail && !ctx && (
|
||||
<p className="py-4 text-center text-xs text-ink-400">暂无详情数据</p>
|
||||
)}
|
||||
|
||||
{!loading && (detail || ctx) && (
|
||||
<div className="space-y-5">
|
||||
{/* 比分区(终场/当前比分 + 状态 + 预测按钮) */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="text-center">
|
||||
<p className="font-serif text-3xl font-bold tabular-nums leading-none text-ink-900">
|
||||
{match.home_goals ?? '-'}{' '}<span className="text-ink-300">:</span>{' '}{match.away_goals ?? '-'}
|
||||
</p>
|
||||
<p className="mt-1 text-2xs text-ink-500">
|
||||
{match.match_stage || ''} {match.match_status === 'finished' ? '· 已完赛' : match.match_status === 'scheduled' ? '· 未开赛' : `· ${match.match_status}`}
|
||||
</p>
|
||||
{match.home_xg != null && match.away_xg != null && (
|
||||
<p className="text-2xs tabular-nums text-ink-400">xG {match.home_xg.toFixed(1)}–{match.away_xg.toFixed(1)}</p>
|
||||
)}
|
||||
</div>
|
||||
{!finished && (
|
||||
<span className="text-2xs text-ink-500">
|
||||
点击行首「预测」按钮发起多专家分析
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 比赛详细统计(bzzoiro /events/{id}/stats/) */}
|
||||
{detail?.stats && (
|
||||
<MatchStatsPanel stats={detail.stats} homeName={homeName} awayName={awayName} />
|
||||
)}
|
||||
|
||||
{/* 双方近况 + H2H */}
|
||||
{(ctx?.home_recent?.length || ctx?.away_recent?.length || ctx?.h2h?.length) ? (
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<RecentBlock title={`${homeName} 近况`} rows={ctx?.home_recent} side="home" />
|
||||
<RecentBlock title={`${awayName} 近况`} rows={ctx?.away_recent} side="away" />
|
||||
<RecentBlock title="历史交锋(H2H)" rows={ctx?.h2h} side="h2h" />
|
||||
</div>
|
||||
) : (
|
||||
!loading && <p className="text-2xs text-ink-400">暂无近期对战数据</p>
|
||||
)}
|
||||
|
||||
{/* 历史预测列表 */}
|
||||
<div>
|
||||
<h4 className="section-head mb-2">历史预测({detail?.recent_predictions?.length ?? 0})</h4>
|
||||
{detail?.recent_predictions?.length ? (
|
||||
<div className="space-y-2">
|
||||
{detail.recent_predictions.map(p => (
|
||||
<PredictionHistoryRow key={p.id} p={p} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-3 text-center text-2xs text-ink-400">该场比赛暂无预测记录</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 赛程表一行:可点击展开详情;展开时懒加载详情(只读,不触发 LLM) */
|
||||
export function MatchRow({
|
||||
m,
|
||||
busy,
|
||||
expanded,
|
||||
detail,
|
||||
ctx,
|
||||
detailLoading,
|
||||
onToggle,
|
||||
onPredict,
|
||||
}: {
|
||||
m: Match
|
||||
busy: boolean
|
||||
expanded: boolean
|
||||
detail: MatchDetailOut | undefined
|
||||
ctx: MatchContextOut | undefined
|
||||
detailLoading: boolean
|
||||
onToggle: () => void
|
||||
onPredict: (m: Match) => void
|
||||
}) {
|
||||
const st = STATUS_META[m.match_status] ?? { label: m.match_status, cls: 'text-ink-400' }
|
||||
const homeName = m.home_team_zh || m.home_team
|
||||
const awayName = m.away_team_zh || m.away_team
|
||||
const finished = m.match_status === 'finished'
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 行:可点击展开 */}
|
||||
<div
|
||||
className={`border-b border-ink-200 px-4 py-5 transition-colors hover:bg-paper-100/70 cursor-pointer sm:px-1 sm:py-4 ${
|
||||
expanded ? 'bg-paper-100/60' : ''
|
||||
}`}
|
||||
onClick={onToggle}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={e => { if (e.key === 'Enter') onToggle() }}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{/* 桌面 grid: 日期 | 主队 | 比分 | 客队 | 状态 | 按钮 */}
|
||||
<div className="flex flex-col gap-3 sm:grid sm:grid-cols-[96px_minmax(0,1fr)_72px_minmax(0,1fr)_64px_88px] sm:items-center sm:gap-x-4 sm:gap-y-0">
|
||||
{/* 日期 + 状态:小屏同行;桌面 date 单独一列 */}
|
||||
<div className="flex items-center justify-between text-xs sm:contents">
|
||||
<span className="tabular-nums text-ink-500 sm:text-xs">{fmtTime(m.match_date)}</span>
|
||||
<span className={`sm:hidden ${st.cls}`}>{st.label}</span>
|
||||
</div>
|
||||
|
||||
{/* 主队 + 比分 + 客队:移动端 grid 三列(严格居中),桌面端 grid 分列 */}
|
||||
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-4 sm:contents">
|
||||
{/* 主队(右对齐) */}
|
||||
<span className="flex min-w-0 items-center justify-end gap-2">
|
||||
<TeamSideTag side="home" />
|
||||
<span className="truncate text-sm font-medium text-ink-900">{homeName}</span>
|
||||
</span>
|
||||
|
||||
{/* 比分 / VS(严格居中) */}
|
||||
<span className="flex flex-col items-center justify-center">
|
||||
{m.home_goals !== null && m.away_goals !== null ? (
|
||||
<span className="font-serif text-xl font-bold tabular-nums leading-none text-ink-900 sm:text-xl">
|
||||
{m.home_goals}<span className="mx-1 font-normal text-ink-300">:</span>{m.away_goals}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm tracking-[0.2em] text-ink-500">VS</span>
|
||||
)}
|
||||
{m.home_xg !== null && m.away_xg !== null && (
|
||||
<span className="mt-0.5 text-2xs tabular-nums text-ink-400">
|
||||
xG {m.home_xg.toFixed(1)}–{m.away_xg.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* 客队(左对齐) */}
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<TeamSideTag side="away" />
|
||||
<span className="truncate text-sm font-medium text-ink-900">{awayName}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 状态标签:小屏隐藏(已有);桌面用徽标样式 */}
|
||||
<span className="hidden text-right sm:block">
|
||||
<span className={`inline-block border px-1.5 py-0.5 text-2xs leading-tight ${st.cls} ${
|
||||
m.match_status === 'finished'
|
||||
? 'border-ink-200 text-ink-500'
|
||||
: m.match_status === 'scheduled'
|
||||
? 'border-ink-300 text-ink-600'
|
||||
: 'border-press/30 text-press'
|
||||
}`}>
|
||||
{st.label}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* 预测按钮(统一,响应式尺寸) */}
|
||||
{!finished && (
|
||||
<div className="flex justify-end" onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={() => onPredict(m)}
|
||||
disabled={busy}
|
||||
className={`btn ${busy ? '' : 'btn-solid'} w-full min-h-[44px] sm:w-[84px] sm:min-h-0 sm:btn-sm`}
|
||||
title="以多专家模式预测这场"
|
||||
>
|
||||
{busy ? (<><Spinner /> 预测中</>) : '预测'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>{/* 关闭可点击行 */}
|
||||
|
||||
{/* 展开详情面板 */}
|
||||
{expanded && (
|
||||
<MatchDetailPanel
|
||||
match={m} detail={detail} ctx={ctx}
|
||||
loading={detailLoading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 行内时间展示:只显示 HH:mm(日期由分组头承担) */
|
||||
function fmtTime(s: string): string {
|
||||
const d = new Date(s)
|
||||
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||
}
|
||||
|
||||
/** 比赛详细统计面板(bzzoiro /events/{id}/stats/) */
|
||||
function MatchStatsPanel({
|
||||
stats, homeName, awayName,
|
||||
}: { stats: MatchStatsDetail; homeName: string; awayName: string }) {
|
||||
const rows: Array<{ label: string; home: number | null; away: number | null; highlight?: 'high' | 'low' }> = [
|
||||
{ label: '预期进球(xG)', home: stats.home_xg, away: stats.away_xg },
|
||||
{ label: '射门', home: stats.home_shots, away: stats.away_shots },
|
||||
{ label: '射正', home: stats.home_shots_on_target, away: stats.away_shots_on_target },
|
||||
{ label: '角球', home: stats.home_corners, away: stats.away_corners },
|
||||
{ label: '犯规', home: stats.home_fouls, away: stats.away_fouls },
|
||||
{ label: '绝佳机会', home: stats.home_big_chances, away: stats.away_big_chances },
|
||||
{ label: '黄牌', home: stats.home_yellow_cards, away: stats.away_yellow_cards },
|
||||
{ label: '红牌', home: stats.home_red_cards, away: stats.away_red_cards },
|
||||
]
|
||||
const hasAny = rows.some(r => r.home != null || r.away != null)
|
||||
if (!hasAny) return null
|
||||
|
||||
// 控球率用横条展示
|
||||
const possHome = stats.home_possession
|
||||
const possAway = possHome != null ? Math.max(0, 100 - possHome) : null
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h4 className="section-head mb-2">比赛统计</h4>
|
||||
|
||||
{/* 控球率横条 */}
|
||||
{possHome != null && possAway != null && (
|
||||
<div className="mb-3">
|
||||
<div className="mb-1 flex justify-between text-2xs text-ink-500">
|
||||
<span>{possHome.toFixed(0)}%</span>
|
||||
<span className="text-ink-400">控球率</span>
|
||||
<span>{possAway.toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="flex h-1.5 overflow-hidden rounded-full bg-ink-200">
|
||||
<div className="bg-ink-700 transition-[width] duration-500" style={{ width: `${possHome}%` }} />
|
||||
<div className="bg-ink-300 transition-[width] duration-500" style={{ width: `${possAway}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 主客对比表 */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-ink-200 text-ink-400">
|
||||
<th className="py-1.5 text-left font-medium">{homeName}</th>
|
||||
<th className="py-1.5 text-center font-medium text-ink-500">统计项</th>
|
||||
<th className="py-1.5 text-right font-medium">{awayName}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.filter(r => r.home != null || r.away != null).map(r => {
|
||||
const h = r.home ?? 0
|
||||
const a = r.away ?? 0
|
||||
const winner = h > a ? 'home' : h < a ? 'away' : 'tie'
|
||||
return (
|
||||
<tr key={r.label} className="border-b border-ink-100">
|
||||
<td className={`py-1.5 text-right tabular-nums ${winner === 'home' ? 'font-bold text-ink-900' : 'text-ink-500'}`}>
|
||||
{r.home ?? '—'}
|
||||
</td>
|
||||
<td className="py-1.5 text-center text-ink-500">{r.label}</td>
|
||||
<td className={`py-1.5 text-left tabular-nums ${winner === 'away' ? 'font-bold text-ink-900' : 'text-ink-500'}`}>
|
||||
{r.away ?? '—'}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 近况/H2H 单区块 */
|
||||
function RecentBlock({ title, rows, side }: { title: string; rows?: TeamRecentMatch[]; side: 'home' | 'away' | 'h2h' }) {
|
||||
return (
|
||||
<div>
|
||||
<h5 className="mb-1.5 text-2xs font-medium text-ink-500">{title}</h5>
|
||||
{rows && rows.length > 0 ? (
|
||||
<ul className="space-y-1">
|
||||
{rows.map((r, i) => {
|
||||
const date = r.match_date ? new Date(r.match_date).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' }) : '—'
|
||||
const score = (r.home_goals != null && r.away_goals != null) ? `${r.home_goals}-${r.away_goals}` : 'vs'
|
||||
const label = side === 'h2h'
|
||||
? `${r.home_team ?? '?'} ${score} ${r.away_team ?? '?'}`
|
||||
: `${score}`
|
||||
return (
|
||||
<li key={i} className="flex items-center justify-between text-2xs tabular-nums text-ink-600">
|
||||
<span className="text-ink-400">{date}</span>
|
||||
<span className="truncate">{label}</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-2xs text-ink-300">暂无</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 历史预测单行(含专家报告入口) */
|
||||
function PredictionHistoryRow({ p }: { p: MatchRecentPrediction }) {
|
||||
const badge = p.status === 'degraded'
|
||||
? { label: 'degraded', cls: 'text-press' }
|
||||
: p.settled
|
||||
? { label: p.correct_1x2 === undefined ? '已结算' : p.correct_1x2 ? '命中' : '未中', cls: p.correct_1x2 ? 'text-ink-900' : 'text-ink-400' }
|
||||
: { label: p.status === 'success' ? '成功' : p.status, cls: 'text-ink-600' }
|
||||
const score = (p.pred_home_goals != null && p.pred_away_goals != null)
|
||||
? `${p.pred_home_goals.toFixed(1)}-${p.pred_away_goals.toFixed(1)}`
|
||||
: '—'
|
||||
const alt = (p.alt_pred_home_goals != null && p.alt_pred_away_goals != null)
|
||||
? `${p.alt_pred_home_goals.toFixed(1)}-${p.alt_pred_away_goals.toFixed(1)}` : null
|
||||
const hasAgents = p.agent_outputs && p.agent_outputs.length > 0
|
||||
|
||||
return (
|
||||
<div className="border-b border-ink-200 pb-2 last:border-b-0">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="tabular-nums text-ink-600">
|
||||
{score} {p.pred_1x2 ? `(${p.pred_1x2})` : ''}
|
||||
{alt && <span className="ml-1 text-ink-400">备选 {alt}</span>}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
{p.subjective_confidence != null && (
|
||||
<span className="text-2xs tabular-nums text-ink-400">信心 {Math.round(p.subjective_confidence * 100)}%</span>
|
||||
)}
|
||||
<span className={`text-2xs ${badge.cls}`}>{badge.label}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center justify-between text-2xs text-ink-400">
|
||||
<span className="truncate">{p.model} · {p.mode} · {p.created_at ? new Date(p.created_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }) : '—'}</span>
|
||||
{hasAgents && <span className="text-press">{p.agent_outputs!.length} 路专家报告</span>}
|
||||
</div>
|
||||
{p.reasoning && (
|
||||
<p className="mt-1 line-clamp-2 font-serif text-2xs leading-relaxed text-ink-500">{p.reasoning}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 详情懒加载取数在此文件内聚:页面只需切换 expandedId 并缓存结果
|
||||
export async function loadMatchDetailBundle(
|
||||
matchId: number,
|
||||
): Promise<{ detail: MatchDetailOut | null; ctx: MatchContextOut | null }> {
|
||||
const [d, c] = await Promise.all([
|
||||
fetchMatchDetail(matchId).catch(() => null),
|
||||
fetchMatchContext(matchId).catch(() => null),
|
||||
])
|
||||
return { detail: d, ctx: c }
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
/**
|
||||
* MatchPredictPanel: 预测弹窗全套(过程可视化 / 结果版面 / 专家意见)。
|
||||
*
|
||||
* D3: 从 Matches.tsx 拆出,渲染逻辑原样搬迁。对外只导出 PredictModal;
|
||||
* PredictionPanel 复用 Prediction 的 embedded 模式由弹窗内渲染。
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import TeamSideTag from '../../../components/TeamSideTag'
|
||||
import type { AgentReport, Match, Prediction } from '../types'
|
||||
import { AGENT_LABELS, CN_NUM, OUTCOME_LABEL } from '../types'
|
||||
|
||||
/** 置信度细线:0~1 数值的低调可视化 */
|
||||
function Meter({ value }: { value: number }) {
|
||||
const pct = Math.max(0, Math.min(100, Math.round(value * 100)))
|
||||
return (
|
||||
<div className="h-px w-full bg-ink-200" role="presentation">
|
||||
<div className="h-px bg-press transition-[width] duration-500" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** home_edge(-1~1,正=利主队)的可视化:以中线为原点的双向细条 */
|
||||
function EdgeBar({ value }: { value: number }) {
|
||||
const v = Math.max(-1, Math.min(1, value))
|
||||
const half = Math.abs(v) * 50
|
||||
return (
|
||||
<div className="relative h-px w-full bg-ink-200" role="presentation">
|
||||
<span className="absolute left-1/2 top-1/2 h-2 w-px -translate-x-1/2 -translate-y-1/2 bg-ink-400" />
|
||||
<span
|
||||
className={`absolute top-0 h-px transition-all duration-500 ${v >= 0 ? 'bg-press' : 'bg-ink-600'}`}
|
||||
style={
|
||||
v >= 0
|
||||
? { left: '50%', width: `${half}%` }
|
||||
: { right: '50%', width: `${half}%` }
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Spinner({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 20 20"
|
||||
className={`h-3.5 w-3.5 animate-spin ${className}`}
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="10" cy="10" r="7.5" stroke="currentColor" strokeWidth="1.5" strokeOpacity="0.25" />
|
||||
<path d="M17.5 10A7.5 7.5 0 0010 2.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** 胜平负一行文字:选中的红字加方块标记,未选中的退灰 */
|
||||
function OutcomeLine({
|
||||
pick,
|
||||
confidence,
|
||||
}: {
|
||||
pick: string | null
|
||||
confidence: number | null
|
||||
}) {
|
||||
const options = ['1', 'X', '2'] as const
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-baseline justify-center gap-6 sm:gap-10">
|
||||
{options.map(o => {
|
||||
const on = pick === o
|
||||
return (
|
||||
<div key={o} className="flex flex-col items-center gap-1">
|
||||
<span className={`flex items-center gap-1.5 text-sm ${on ? 'font-semibold text-press' : 'text-ink-400'}`}>
|
||||
{on && <span className="inline-block h-2 w-2 bg-press" aria-hidden="true" />}
|
||||
{OUTCOME_LABEL[o]}
|
||||
</span>
|
||||
{on && confidence !== null && (
|
||||
<span className="text-2xs tabular-nums text-ink-500">
|
||||
置信 {Math.round(confidence * 100)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{pick && confidence !== null && (
|
||||
<div className="mx-auto mt-3 max-w-xs">
|
||||
<Meter value={confidence} />
|
||||
<p className="mt-1 text-center text-2xs text-ink-400">主观置信度,非统计概率</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 预测成本展示:耗时 + token + 限流余量 */
|
||||
function PredictionCost({ prediction }: { prediction: Prediction }) {
|
||||
const latency = prediction.latency_ms != null ? `${(prediction.latency_ms / 1000).toFixed(1)}s` : null
|
||||
const tokens = prediction.prompt_tokens != null || prediction.completion_tokens != null
|
||||
? `${prediction.prompt_tokens ?? '?'}/${prediction.completion_tokens ?? '?'}`
|
||||
: null
|
||||
|
||||
if (!latency && !tokens && prediction.rate_limit_remaining == null) return null
|
||||
|
||||
return (
|
||||
<div className="border-t border-ink-200 pt-3 text-2xs text-ink-500">
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1">
|
||||
{latency && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span aria-hidden="true" className="opacity-60">⏱</span>耗时 {latency}
|
||||
</span>
|
||||
)}
|
||||
{tokens && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span aria-hidden="true" className="opacity-60">Tok</span>prompt/completion: {tokens}
|
||||
</span>
|
||||
)}
|
||||
{prediction.rate_limit_remaining != null && prediction.rate_limit_remaining <= 3 && (
|
||||
<span className="text-press" title="每分钟最多 10 次预测">
|
||||
剩余配额: {prediction.rate_limit_remaining}/10(分钟)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 预测过程阶段(按时长模拟;结果到达即跳到完成) */
|
||||
function PredictProgress() {
|
||||
const [elapsed, setElapsed] = useState(0)
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => setElapsed(e => e + 0.5), 500)
|
||||
return () => clearInterval(t)
|
||||
}, [])
|
||||
|
||||
// 阶段阈值(秒): 切片 → 专家(各路依次点亮) → 终裁
|
||||
const SLICE_END = 3
|
||||
const AGENT_START = 4
|
||||
const AGENT_STEP = 8 // 每路专家约 8s 点亮一路
|
||||
const AGG_START = AGENT_START + AGENT_STEP * 5
|
||||
const agents = ['form', 'stats', 'home_away', 'standings', 'h2h']
|
||||
|
||||
const phase = elapsed < SLICE_END ? 'slice'
|
||||
: elapsed < AGG_START ? 'agents' : 'agg'
|
||||
|
||||
const pct = Math.min(95, Math.round((elapsed / 70) * 100))
|
||||
|
||||
return (
|
||||
<div className="px-5 py-8 sm:px-8">
|
||||
{/* 阶段标题 */}
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Spinner className="text-press" />
|
||||
<span className="font-serif text-sm font-bold text-ink-900">
|
||||
{phase === 'slice' && '正在组装比赛数据切片'}
|
||||
{phase === 'agents' && '五路专家并行分析中'}
|
||||
{phase === 'agg' && '终裁专家汇总裁定中'}
|
||||
</span>
|
||||
<span className="text-2xs tabular-nums text-ink-400">{elapsed.toFixed(0)}s</span>
|
||||
</div>
|
||||
|
||||
{/* 进度条:渐进式,不封顶到 100% */}
|
||||
<div className="mx-auto mt-5 h-1 w-full max-w-md overflow-hidden bg-ink-100" role="progressbar" aria-valuenow={pct}>
|
||||
<div
|
||||
className={`h-full bg-press transition-all duration-500 ${phase === 'agg' ? 'animate-pulse' : ''}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 专家灯序(多专家模式) */}
|
||||
<ul className="mx-auto mt-6 max-w-md space-y-1.5">
|
||||
{agents.map((a, i) => {
|
||||
const lit = elapsed >= AGENT_START + AGENT_STEP * (i + 1)
|
||||
const activeNow = !lit && elapsed >= AGENT_START + AGENT_STEP * i
|
||||
return (
|
||||
<li
|
||||
key={a}
|
||||
className={`flex items-center justify-between border-b border-ink-200 pb-1.5 text-xs transition-colors ${
|
||||
lit ? 'text-ink-800' : activeNow ? 'text-ink-900' : 'text-ink-300'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`inline-block h-1.5 w-1.5 ${lit ? 'bg-ink-900' : activeNow ? 'bg-press animate-pulse' : 'bg-ink-200'}`}
|
||||
/>
|
||||
{AGENT_LABELS[a] ?? a}
|
||||
</span>
|
||||
{lit && <span className="text-2xs text-ink-400">✓ 完成</span>}
|
||||
{activeNow && <span className="text-2xs text-press">分析中…</span>}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<p className="mt-6 text-center text-2xs text-ink-400">
|
||||
五路专家并行分析后终裁,约需 30-90 秒;多专家调用消耗较多 token,请按需使用。关闭窗口即取消
|
||||
</p>
|
||||
<p className="mt-1 text-center text-2xs text-ink-300">
|
||||
提示:每分钟限 10 次预测,耗尽后需等待下一分钟。
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const STATUS_BADGE: Record<string, { label: string; cls: string }> = {
|
||||
ok: { label: '正常', cls: 'text-ink-500' },
|
||||
no_data: { label: '无数据', cls: 'text-ink-400' },
|
||||
error: { label: '调用失败', cls: 'text-press' },
|
||||
parse_error: { label: '解析失败', cls: 'text-press' },
|
||||
}
|
||||
|
||||
const SUFFICIENCY_LABEL: Record<string, string> = {
|
||||
high: '充分',
|
||||
medium: '一般',
|
||||
low: '偏少',
|
||||
none: '无',
|
||||
}
|
||||
|
||||
/** 单路专家意见:汉字编号 + 细线行 */
|
||||
function AgentCard({ report: r, no }: { report: AgentReport; no: string }) {
|
||||
const badge = STATUS_BADGE[r.status] ?? { label: r.status, cls: 'text-ink-400' }
|
||||
const inactive = r.status !== 'ok'
|
||||
|
||||
return (
|
||||
<details className="group border-b border-ink-200">
|
||||
<summary className="flex cursor-pointer list-none items-baseline gap-2.5 px-1 py-3">
|
||||
<span className="font-serif text-sm text-ink-400">{no}</span>
|
||||
<span className="text-sm font-medium text-ink-900">{AGENT_LABELS[r.agent] ?? r.agent}</span>
|
||||
<span className={`text-2xs ${badge.cls}`}>{badge.label}</span>
|
||||
|
||||
<span className="ml-auto flex items-baseline gap-3 text-2xs tabular-nums text-ink-500">
|
||||
{r.status === 'ok' && r.subjective_confidence !== null && (
|
||||
<span>信心 {Math.round(r.subjective_confidence * 100)}%</span>
|
||||
)}
|
||||
{r.status === 'ok' && r.probable_score && (
|
||||
<span className="font-serif font-bold text-ink-800">{r.probable_score}</span>
|
||||
)}
|
||||
<svg viewBox="0 0 20 20" className="h-3 w-3 self-center text-ink-300 transition-transform group-open:rotate-90" fill="currentColor" aria-hidden="true">
|
||||
<path d="M7.3 5.3a1 1 0 011.4 0l4 4a1 1 0 010 1.4l-4 4a1 1 0 01-1.4-1.4L10.6 10 7.3 6.7a1 1 0 010-1.4z" />
|
||||
</svg>
|
||||
</span>
|
||||
</summary>
|
||||
|
||||
<div className="space-y-3 px-1 pb-4 pl-7">
|
||||
{/* 无数据 / 失败时给出明确说明,避免用户以为是空白 bug */}
|
||||
{inactive && (
|
||||
<p className="text-xs leading-relaxed text-ink-500">
|
||||
{r.status === 'no_data' && '该维度没有可用数据,已跳过 LLM 分析以节省额度(不影响其他专家)。'}
|
||||
{r.status === 'error' && '该专家调用失败,本次结论未纳入其视角(fail-open 设计,不阻断整体预测)。'}
|
||||
{r.status === 'parse_error' && '模型输出未通过格式校验,该报告已丢弃。'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!inactive && r.home_edge !== null && (
|
||||
<div>
|
||||
<div className="mb-1.5 flex items-baseline justify-between text-2xs">
|
||||
<span className="text-ink-500">主队优势</span>
|
||||
<span className={`font-semibold tabular-nums ${r.home_edge > 0 ? 'text-press' : r.home_edge < 0 ? 'text-ink-700' : 'text-ink-500'}`}>
|
||||
{r.home_edge > 0 ? '+' : ''}{r.home_edge.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<EdgeBar value={r.home_edge} />
|
||||
<div className="mt-1 flex justify-between text-2xs text-ink-400">
|
||||
<span>利客队</span>
|
||||
<span>利主队</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{r.analysis && (
|
||||
<p className="font-serif text-sm leading-loose text-ink-700">{r.analysis}</p>
|
||||
)}
|
||||
|
||||
{r.key_evidence.length > 0 && (
|
||||
<ul className="space-y-1.5">
|
||||
{r.key_evidence.map((e, i) => (
|
||||
<li key={i} className="flex gap-2 text-xs leading-relaxed text-ink-600">
|
||||
<span className="flex-shrink-0 text-ink-300" aria-hidden="true">—</span>
|
||||
<span>{e}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{r.exp_home_goals !== null && r.exp_away_goals !== null && (
|
||||
<p className="text-xs text-ink-500">
|
||||
进球期望 <span className="font-serif font-bold tabular-nums text-ink-900">{r.exp_home_goals.toFixed(1)} - {r.exp_away_goals.toFixed(1)}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!inactive && (
|
||||
<p className="border-t border-ink-100 pt-2.5 text-2xs text-ink-400">
|
||||
数据充分度 {SUFFICIENCY_LABEL[r.data_sufficiency] ?? r.data_sufficiency}
|
||||
<span className="mx-2 text-ink-200">|</span>
|
||||
<span className="font-mono">{r.model}</span>
|
||||
{r.latency_ms !== null && <span className="ml-2 tabular-nums">{r.latency_ms}ms</span>}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
|
||||
function PredictionPanel({
|
||||
prediction,
|
||||
match,
|
||||
embedded = false,
|
||||
}: {
|
||||
prediction: Prediction
|
||||
match: Match
|
||||
/** 弹窗嵌入模式:弹窗已提供报头,这里省略自带版头 */
|
||||
embedded?: boolean
|
||||
}) {
|
||||
const homeName = match.home_team_zh || match.home_team
|
||||
const [expertsOpen, setExpertsOpen] = useState(false)
|
||||
const awayName = match.away_team_zh || match.away_team
|
||||
const degraded = prediction.status === 'degraded' || prediction.status === 'failed'
|
||||
const reports = prediction.agent_outputs ?? []
|
||||
const okReports = reports.filter(r => r.status === 'ok')
|
||||
|
||||
return (
|
||||
<article className={embedded ? 'bg-paper-50' : 'border border-ink-900 bg-paper-50'}>
|
||||
{!embedded && (
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2 border-b border-ink-900 bg-paper-100 px-4 py-2.5 sm:px-5">
|
||||
<h3 className="flex flex-wrap items-center gap-1.5 font-serif text-sm font-bold text-ink-900">
|
||||
预测版 ·
|
||||
<TeamSideTag side="home" />
|
||||
{homeName}
|
||||
<span>对</span>
|
||||
<TeamSideTag side="away" />
|
||||
{awayName}
|
||||
</h3>
|
||||
<span className="text-2xs tabular-nums text-ink-500">
|
||||
{prediction.provider} / {prediction.model}
|
||||
{prediction.latency_ms !== null && ` · ${(prediction.latency_ms / 1000).toFixed(1)}s`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-7 px-4 py-6 sm:px-5">
|
||||
{/* ── degraded / failed 态:醒目警示 + 原因,不展示虚假比分 ── */}
|
||||
{degraded && (
|
||||
<div className="border-l-2 border-press bg-press-wash/40 px-4 py-3">
|
||||
<p className="font-serif text-sm font-bold text-press-dark">
|
||||
{prediction.status === 'failed' ? '预测失败' : '预测降级(degraded)'}
|
||||
</p>
|
||||
<p className="mt-1.5 whitespace-pre-wrap text-xs leading-relaxed text-ink-600">
|
||||
{prediction.reasoning || '所有专家均无有效数据或调用失败,无法生成可靠比分。'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 主结论(仅 success 展示) ── */}
|
||||
{!degraded && (
|
||||
<>
|
||||
<div className="text-center">
|
||||
<p className="font-serif text-5xl font-bold tabular-nums leading-none text-ink-900 sm:text-6xl">
|
||||
{prediction.pred_home_goals ?? '-'}
|
||||
<span className="mx-3 font-normal text-ink-300">:</span>
|
||||
{prediction.pred_away_goals ?? '-'}
|
||||
</p>
|
||||
<p className="mt-3 text-2xs tracking-[0.5em] text-ink-400">预测比分</p>
|
||||
{prediction.alt_pred_home_goals != null && prediction.alt_pred_away_goals != null && (
|
||||
<p className="mt-2 text-2xs tabular-nums text-ink-400">
|
||||
备选{' '}
|
||||
<span className="font-serif text-sm font-bold tabular-nums text-ink-600">
|
||||
{prediction.alt_pred_home_goals}<span className="mx-0.5 font-normal text-ink-300">:</span>{prediction.alt_pred_away_goals}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-y border-ink-200 py-4">
|
||||
<OutcomeLine pick={prediction.pred_1x2} confidence={prediction.subjective_confidence} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── 成本信息(耗时 + token + 限流余量) ── */}
|
||||
{!degraded && (
|
||||
<PredictionCost prediction={prediction} />
|
||||
)}
|
||||
|
||||
{/* ── 元信息 ── */}
|
||||
<p className="text-center text-2xs text-ink-500">
|
||||
`多专家模式 · ${okReports.length}/${reports.length} 路有效`
|
||||
{prediction.prompt_version && ` · prompt ${prediction.prompt_version}`}
|
||||
</p>
|
||||
|
||||
{/* ── 终裁/降级说明意见 ── */}
|
||||
{prediction.reasoning && degraded && (
|
||||
<section>
|
||||
<h4 className="section-head mb-2">降级原因</h4>
|
||||
<blockquote className="border-l-2 border-press pl-4">
|
||||
<p className="whitespace-pre-wrap font-serif text-sm leading-loose text-ink-700">{prediction.reasoning}</p>
|
||||
</blockquote>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── 专家意见(多模式):可折叠 + 状态摘要 + 权重条形图 ── */}
|
||||
{reports.length > 0 && (
|
||||
<section>
|
||||
<button
|
||||
onClick={() => setExpertsOpen(o => !o)}
|
||||
className="flex w-full items-center justify-between border-b border-ink-200 pb-2 text-left"
|
||||
>
|
||||
<span className="section-head mb-0">五路专家意见({okReports.length}/{reports.length} 路有效)</span>
|
||||
<span className="text-2xs text-ink-400">{expertsOpen ? '收起' : '展开'}</span>
|
||||
</button>
|
||||
|
||||
{/* 权重条形图(仅 success 且有权重时显示) */}
|
||||
{!degraded && prediction.agent_weights && Object.keys(prediction.agent_weights).length > 0 && (
|
||||
<div className="mt-3 space-y-1.5">
|
||||
<span className="text-2xs text-ink-500">终裁权重分布</span>
|
||||
{Object.entries(prediction.agent_weights)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([k, v]) => (
|
||||
<div key={k} className="grid grid-cols-[96px_minmax(0,1fr)_40px] items-center gap-2">
|
||||
<span className="truncate text-2xs text-ink-500">{AGENT_LABELS[k] ?? k}</span>
|
||||
<div className="h-1.5 bg-paper-100">
|
||||
<div className="h-full bg-press" style={{ width: `${Math.round(v * 100)}%` }} />
|
||||
</div>
|
||||
<span className="text-right text-2xs tabular-nums text-ink-500">{Math.round(v * 100)}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expertsOpen && (
|
||||
<div className="mt-2">
|
||||
{reports.map((r, i) => (
|
||||
<AgentCard key={r.agent} report={r} no={CN_NUM[i] ?? String(i + 1)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── 终裁意见(success) ── */}
|
||||
{prediction.reasoning && !degraded && (
|
||||
<section>
|
||||
<h4 className="section-head mb-3">终裁意见</h4>
|
||||
<blockquote className="border-l-2 border-press pl-4">
|
||||
<p className="whitespace-pre-wrap font-serif text-sm leading-loose text-ink-700">{prediction.reasoning}</p>
|
||||
</blockquote>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
/** 预测弹窗:进行中显示过程可视化,完成后显示预测版,失败显示原因 */
|
||||
export function PredictModal({
|
||||
match,
|
||||
predicting,
|
||||
prediction,
|
||||
error,
|
||||
onClose,
|
||||
}: {
|
||||
match: Match
|
||||
predicting: boolean
|
||||
prediction: Prediction | null
|
||||
error: string | null
|
||||
onClose: () => void
|
||||
}) {
|
||||
const homeName = match.home_team_zh || match.home_team
|
||||
const awayName = match.away_team_zh || match.away_team
|
||||
|
||||
useEffect(() => {
|
||||
const h = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('keydown', h)
|
||||
return () => document.removeEventListener('keydown', h)
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-ink-900/50 p-4 sm:items-center"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`预测 ${homeName} 对 ${awayName}`}
|
||||
onClick={e => {
|
||||
if (e.target === e.currentTarget) onClose()
|
||||
}}
|
||||
>
|
||||
<div className="relative flex max-h-[92vh] w-full max-w-2xl flex-col overflow-hidden bg-paper-50 shadow-2xl">
|
||||
{/* 弹窗报头 */}
|
||||
<div className="flex flex-shrink-0 items-center justify-between border-b border-ink-900 bg-paper-100 px-4 py-2.5 sm:px-5">
|
||||
<h3 className="flex flex-wrap items-center gap-1.5 font-serif text-sm font-bold text-ink-900">
|
||||
预测版 ·
|
||||
<TeamSideTag side="home" />
|
||||
{homeName}
|
||||
<span>对</span>
|
||||
<TeamSideTag side="away" />
|
||||
{awayName}
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex h-11 w-11 flex-shrink-0 items-center justify-center text-ink-400 transition-colors hover:text-ink-900"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path d="M6.3 5.3a1 1 0 011.4 0L10 7.6l2.3-2.3a1 1 0 111.4 1.4L11.4 9l2.3 2.3a1 1 0 01-1.4 1.4L10 10.4l-2.3 2.3a1 1 0 01-1.4-1.4L8.6 9 6.3 6.7a1 1 0 010-1.4z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 弹窗体(小屏可滚动) */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{predicting ? (
|
||||
<PredictProgress />
|
||||
) : error ? (
|
||||
<div className="px-5 py-10 text-center sm:px-8">
|
||||
<p className="font-serif text-sm font-bold text-press">预测失败</p>
|
||||
<p className="mx-auto mt-3 max-w-md whitespace-pre-wrap text-left text-xs leading-relaxed text-ink-600">
|
||||
{error}
|
||||
</p>
|
||||
<button onClick={onClose} className="btn btn-sm mt-6">关闭</button>
|
||||
</div>
|
||||
) : prediction ? (
|
||||
<PredictionPanel prediction={prediction} match={match} embedded />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 公开站联赛列表(P1-3):优先请求 GET /api/v1/leagues,
|
||||
* 失败或返回空数组则回退本地五大联赛常量(LEAGUES)。
|
||||
*
|
||||
* 显示名规则:常量里已有的 code 沿用中文标签(保持现有 UI 语言不变),
|
||||
* 新增联赛用 API 返回的 name;排序按常量顺序优先、新联赛按 API 返回序追加。
|
||||
* API 仅返回 {id, code, name, country},无敏感配置字段。
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { fetchLeagues } from '../../../admin/dal'
|
||||
import { LEAGUES } from '../types'
|
||||
|
||||
export function useLeagues(): { code: string; name: string }[] {
|
||||
const [leagues, setLeagues] = useState(LEAGUES)
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
;(async () => {
|
||||
// dal.fetchLeagues 已兜底:网络/权限异常时返回 []
|
||||
const rows = await fetchLeagues()
|
||||
if (!alive || rows.length === 0) return
|
||||
const zhName = new Map(LEAGUES.map(l => [l.code, l.name] as const))
|
||||
const rank = new Map(LEAGUES.map((l, i) => [l.code, i] as const))
|
||||
const merged = rows
|
||||
.slice()
|
||||
.sort(
|
||||
(a, b) => (rank.get(a.code) ?? LEAGUES.length) - (rank.get(b.code) ?? LEAGUES.length),
|
||||
)
|
||||
.map(l => ({ code: l.code, name: zhName.get(l.code) ?? l.name }))
|
||||
setLeagues(merged)
|
||||
})()
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
return leagues
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* useMatchPredict: 预测流程状态机(发起/进行中/结果/失败/关闭中止)。
|
||||
*
|
||||
* D3: 从 Matches.tsx 拆出。语义不变:
|
||||
* - 连点防护:同一场比赛预测中再次点击直接忽略
|
||||
* - 竞态防护:递增序号,过期响应丢弃
|
||||
* - 关闭弹窗 = 中止在途请求 + 序号失效(catch/then 不再写入)
|
||||
* - 5 分钟超时,与 nginx 代理 300s 对齐
|
||||
*/
|
||||
import { useRef, useState } from 'react'
|
||||
import { http } from '../../../lib/http'
|
||||
import type { Match, Prediction } from '../types'
|
||||
|
||||
/** 把后端/网络错误翻译成用户可读文案 */
|
||||
function readablePredictError(e: unknown): string {
|
||||
if (e instanceof Error) {
|
||||
const m = e.message
|
||||
if (/429/.test(m)) {
|
||||
// 429 来自后端限流(每分钟 10 次),非上游 LLM
|
||||
return '操作过于频繁:每分钟最多 10 次预测。为保护 LLM 额度,请稍后再试。'
|
||||
}
|
||||
if (/502/.test(m)) return 'LLM 服务暂时不可用(502),请稍后重试'
|
||||
if (/402|Payment Required|额度|余额/.test(m)) return 'LLM 额度不足(402),请检查 API Key 余额'
|
||||
if (/400|已完赛/.test(m)) return '该比赛已完赛,不再支持预测'
|
||||
if (/409|已结算/.test(m)) return '该预测已结算,不能重新预测'
|
||||
if (/timeout|超时|timed out/i.test(m)) return '请求超时,请稍后重试'
|
||||
return m
|
||||
}
|
||||
return String(e)
|
||||
}
|
||||
|
||||
interface UseMatchPredictOptions {
|
||||
/** 共享 error state(拆分前列表与预测共用同一个 error,行为保持一致) */
|
||||
onError: (msg: string | null) => void
|
||||
}
|
||||
|
||||
export function useMatchPredict({ onError }: UseMatchPredictOptions) {
|
||||
const [predictingId, setPredictingId] = useState<number | null>(null)
|
||||
const [prediction, setPrediction] = useState<Prediction | null>(null)
|
||||
const [predictionFor, setPredictionFor] = useState<Match | null>(null)
|
||||
const predictSeq = useRef(0)
|
||||
// 预测请求控制器:关闭弹窗时中止
|
||||
const predictAbort = useRef<AbortController | null>(null)
|
||||
|
||||
function closePredict() {
|
||||
predictAbort.current?.abort()
|
||||
predictSeq.current++ // 令中止请求的 catch/then 全部失效,不再写入错误
|
||||
setPredictingId(null)
|
||||
setPrediction(null)
|
||||
setPredictionFor(null)
|
||||
onError(null)
|
||||
}
|
||||
|
||||
const predict = async (m: Match) => {
|
||||
// 防连点:若该场比赛已在预测中,直接忽略
|
||||
if (predictingId === m.id) return
|
||||
const seq = ++predictSeq.current
|
||||
setPredictingId(m.id)
|
||||
onError(null)
|
||||
setPrediction(null)
|
||||
setPredictionFor(m)
|
||||
// LLM 多专家预测耗时可达数分钟,给足超时(与 nginx 代理 300s 对齐)
|
||||
const controller = new AbortController()
|
||||
predictAbort.current = controller
|
||||
const timer = setTimeout(() => controller.abort(), 300_000)
|
||||
try {
|
||||
const data = await http.post<Prediction>('/predict', { match_id: m.id, mode: 'multi' }, {
|
||||
timeoutMs: 300_000,
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (seq !== predictSeq.current) return
|
||||
setPrediction(data)
|
||||
} catch (e) {
|
||||
if (seq !== predictSeq.current) return
|
||||
onError(
|
||||
e instanceof DOMException && e.name === 'AbortError'
|
||||
? '预测超时(5 分钟),请稍后重试'
|
||||
: readablePredictError(e),
|
||||
)
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
if (seq === predictSeq.current) setPredictingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
predictingId,
|
||||
prediction,
|
||||
predictionFor,
|
||||
predict,
|
||||
closePredict,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* useMatchesList: 赛程列表数据获取(筛选/游标分页/进行中比赛)。
|
||||
*
|
||||
* D3: 从 Matches.tsx 拆出。竞态防护语义不变 —— 递增序号只认最后一次请求;
|
||||
* loadMore 不自增序号(切换筛选才自增,翻页跟随当前序列)。
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { http } from '../../../lib/http'
|
||||
import type { Match } from '../types'
|
||||
|
||||
interface UseMatchesListOptions {
|
||||
/** 共享 error state(拆分前列表与预测共用同一个 error,行为保持一致) */
|
||||
onError: (msg: string | null) => void
|
||||
}
|
||||
|
||||
export function useMatchesList({ onError }: UseMatchesListOptions) {
|
||||
const [league, setLeague] = useState('E0')
|
||||
const [status, setStatus] = useState('scheduled')
|
||||
const [matches, setMatches] = useState<Match[]>([])
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showAllUpcoming, setShowAllUpcoming] = useState(false) // 默认仅展示未来 3 天;true 展开全部
|
||||
const [liveMatches, setLiveMatches] = useState<Match[]>([]) // 进行中比赛(顶部独立区块)
|
||||
|
||||
// 请求竞态防护:切换联赛/状态很快时,先发的慢请求可能后返回,
|
||||
// 把旧结果覆盖到新筛选上。用递增序号只认最后一次请求的响应。
|
||||
const loadSeq = useRef(0)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const seq = ++loadSeq.current
|
||||
setLoading(true)
|
||||
setLoadingMore(false)
|
||||
setShowAllUpcoming(false) // 切换筛选重置为「未来 3 天」视图
|
||||
onError(null)
|
||||
try {
|
||||
const params = new URLSearchParams({ league, status, limit: '50' })
|
||||
const data = await http.get<{ items: Match[]; next_cursor: string | null }>(`/matches?${params}`)
|
||||
if (seq !== loadSeq.current) return // 已有更新的请求,丢弃本次结果
|
||||
setMatches(data.items)
|
||||
setNextCursor(data.next_cursor ?? null)
|
||||
} catch (e) {
|
||||
if (seq !== loadSeq.current) return
|
||||
onError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
if (seq === loadSeq.current) setLoading(false)
|
||||
}
|
||||
}, [league, status, onError])
|
||||
|
||||
// 加载下一页(游标分页)
|
||||
const loadMore = async () => {
|
||||
if (!nextCursor || loadingMore) return
|
||||
const seq = loadSeq.current // 不做自增:切换筛选会自增,这里只跟随当前序列
|
||||
setLoadingMore(true)
|
||||
try {
|
||||
const params = new URLSearchParams({ league, status, limit: '50', cursor: nextCursor })
|
||||
const data = await http.get<{ items: Match[]; next_cursor: string | null }>(`/matches?${params}`)
|
||||
if (seq !== loadSeq.current) return
|
||||
setMatches(prev => [...prev, ...data.items])
|
||||
setNextCursor(data.next_cursor ?? null)
|
||||
} catch (e) {
|
||||
if (seq !== loadSeq.current) return
|
||||
onError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
if (seq === loadSeq.current) setLoadingMore(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载进行中比赛(顶部独立区块)
|
||||
const loadLive = useCallback(async () => {
|
||||
try {
|
||||
const params = new URLSearchParams({ league, status: 'in_play', limit: '20' })
|
||||
const data = await http.get<{ items: Match[] }>(`/matches?${params}`)
|
||||
setLiveMatches(data.items ?? [])
|
||||
} catch {
|
||||
/* ignore:进行中非核心功能 */
|
||||
}
|
||||
}, [league])
|
||||
|
||||
useEffect(() => { load(); loadLive() }, [load, loadLive])
|
||||
|
||||
return {
|
||||
league, setLeague,
|
||||
status, setStatus,
|
||||
matches,
|
||||
nextCursor,
|
||||
loading,
|
||||
loadingMore,
|
||||
showAllUpcoming, setShowAllUpcoming,
|
||||
liveMatches,
|
||||
load,
|
||||
loadMore,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Matches 页面族共享类型与常量。
|
||||
*
|
||||
* D3(工程债): Matches.tsx 原本 ~1400 行,类型/常量/预测面板/详情面板/
|
||||
* 列表逻辑全部内联。本文件是拆分后的共享层 —— 只放数据契约与纯常量,
|
||||
* 不含 React 组件。
|
||||
*/
|
||||
|
||||
/** /matches 列表项(公开接口) */
|
||||
export interface Match {
|
||||
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
|
||||
}
|
||||
|
||||
/** POST /predict 响应 */
|
||||
export interface Prediction {
|
||||
prediction_id: number
|
||||
provider: string
|
||||
model: string
|
||||
prompt_version: string | null
|
||||
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
|
||||
agent_outputs: AgentReport[] | null
|
||||
agent_weights: Record<string, number> | null
|
||||
context: string
|
||||
latency_ms: number | null
|
||||
prompt_tokens: number | null
|
||||
completion_tokens: number | null
|
||||
rate_limit_remaining: number | null
|
||||
}
|
||||
|
||||
/** 多专家单路报告 */
|
||||
export interface AgentReport {
|
||||
agent: string
|
||||
status: string
|
||||
data_sufficiency: string
|
||||
analysis: string
|
||||
home_edge: number | null
|
||||
subjective_confidence: number | null
|
||||
key_evidence: string[]
|
||||
exp_home_goals: number | null
|
||||
exp_away_goals: number | null
|
||||
probable_score: string | null
|
||||
model: string
|
||||
latency_ms: number | null
|
||||
}
|
||||
|
||||
export const AGENT_LABELS: Record<string, string> = {
|
||||
h2h: '历史交锋分析专家',
|
||||
form: '近期状态分析专家',
|
||||
stats: '攻防数据分析专家',
|
||||
home_away: '主客因素分析专家',
|
||||
standings: '联赛排名分析专家',
|
||||
}
|
||||
|
||||
export const LEAGUES = [
|
||||
{ code: 'E0', name: '英超' },
|
||||
{ code: 'SP1', name: '西甲' },
|
||||
{ code: 'D1', name: '德甲' },
|
||||
{ code: 'I1', name: '意甲' },
|
||||
{ code: 'F1', name: '法甲' },
|
||||
]
|
||||
|
||||
/** 汉字编号,给专家意见排版用 */
|
||||
export const CN_NUM = ['一', '二', '三', '四', '五', '六', '七', '八']
|
||||
|
||||
export const STATUS_META: Record<string, { label: string; cls: string }> = {
|
||||
finished: { label: '已完赛', cls: 'text-ink-400' },
|
||||
scheduled: { label: '未开赛', cls: 'text-ink-600' },
|
||||
// 键与 normalize.py 的 VALID_STATUS 对齐: 库里存的是 in_play(上游 live 被归一化),不存在 'live' 状态
|
||||
in_play: { label: '进行中', cls: 'text-press font-medium' },
|
||||
paused: { label: '暂停', cls: 'text-press font-medium' },
|
||||
postponed: { label: '延期', cls: 'text-ink-400' },
|
||||
cancelled: { label: '取消', cls: 'text-ink-400' },
|
||||
suspended: { label: '中止', cls: 'text-ink-400' },
|
||||
}
|
||||
|
||||
/** 1x2 → 中文标签 */
|
||||
export const OUTCOME_LABEL: Record<string, string> = { '1': '主胜', X: '平局', '2': '客胜' }
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Matches 页面族共享的原子 UI 小件(无业务状态)。
|
||||
*
|
||||
* D3: 从 Matches.tsx 内联定义上移到模块级 —— Switch 原先定义在组件函数
|
||||
* 体内(每次渲染重建组件对象),它没有内部 state,提升后渲染结果一致。
|
||||
*/
|
||||
import type { Match } from './types'
|
||||
|
||||
export function Spinner({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 20 20"
|
||||
className={`h-3.5 w-3.5 animate-spin ${className}`}
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="10" cy="10" r="7.5" stroke="currentColor" strokeWidth="1.5" strokeOpacity="0.25" />
|
||||
<path d="M17.5 10A7.5 7.5 0 0010 2.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** 骨架占位行:低调脉动灰块 */
|
||||
export function SkeletonRows({ n = 4 }: { n?: number }) {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: n }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4 border-b border-ink-200 px-1 py-3.5">
|
||||
<div className="skeleton h-3 w-16" />
|
||||
<div className="skeleton h-3 flex-1" />
|
||||
<div className="skeleton h-3 w-10" />
|
||||
<div className="skeleton h-3 flex-1" />
|
||||
<div className="skeleton h-3 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** 状态/模式一组的文字切换 */
|
||||
export function Switch({ value, onChange, items }: {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
items: { v: string; label: string; title?: string }[]
|
||||
}) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2.5">
|
||||
{items.map((it, i) => (
|
||||
<span key={it.v} className="inline-flex items-center gap-2.5">
|
||||
{i > 0 && <span className="text-ink-300" aria-hidden="true">/</span>}
|
||||
<button
|
||||
onClick={() => onChange(it.v)}
|
||||
title={it.title}
|
||||
className={`relative tab ${value === it.v ? 'tab-on' : ''} text-xs`}
|
||||
>
|
||||
{it.label}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** 日期分组头显示:今日/明天/周几 · 年月日 */
|
||||
export function formatDateHeader(dateKey: string): string {
|
||||
if (!dateKey) return '未开赛'
|
||||
const d = new Date(dateKey + 'T00:00:00')
|
||||
if (isNaN(d.getTime())) return dateKey
|
||||
const today = new Date()
|
||||
const todayKey = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
|
||||
const tmr = new Date(today)
|
||||
tmr.setDate(tmr.getDate() + 1)
|
||||
const tmrKey = `${tmr.getFullYear()}-${String(tmr.getMonth() + 1).padStart(2, '0')}-${String(tmr.getDate()).padStart(2, '0')}`
|
||||
const weekday = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][d.getDay()]
|
||||
if (dateKey === todayKey) return `今日 ${weekday}`
|
||||
if (dateKey === tmrKey) return `明日 ${weekday}`
|
||||
return `${d.getMonth() + 1}月${d.getDate()}日 ${weekday}`
|
||||
}
|
||||
|
||||
/** UTC ISO → 本地日期 YYYY-MM-DD(用于分组) */
|
||||
export function toLocalDateKey(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/** 按本地日期分组(非 UTC),保持时间序 */
|
||||
export function groupByDate(list: Match[]): Array<[string, Match[]]> {
|
||||
const map = new Map<string, Match[]>()
|
||||
for (const m of list) {
|
||||
const key = toLocalDateKey(m.match_date)
|
||||
const arr = map.get(key)
|
||||
if (arr) arr.push(m)
|
||||
else map.set(key, [m])
|
||||
}
|
||||
return [...map.entries()]
|
||||
}
|
||||
|
||||
/** 日期 key 辅助:YYYY-MM-DD(本地时区) */
|
||||
function dateKey(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/** 未来 3 天窗口:今天 00:00 → 第 3 天 00:00(即今天/明天/后天) */
|
||||
function addDays(d: Date, n: number): string {
|
||||
const x = new Date(d)
|
||||
x.setFullYear(x.getFullYear(), x.getMonth(), x.getDate() + n)
|
||||
return dateKey(x)
|
||||
}
|
||||
|
||||
/** 比赛是否在未来 3 天内(用于默认视图过滤) */
|
||||
export function withinNext3Days(matchDate: string): boolean {
|
||||
const key = toLocalDateKey(matchDate)
|
||||
return key >= dateKey(new Date()) && key < addDays(new Date(), 3)
|
||||
}
|
||||
@@ -32,6 +32,8 @@ export default {
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
// 毛体草书(国内 CDN)+ 粗楷体回退
|
||||
brush: ['"Liu Jian Mao Cao"', '"KaiTi"', '"STKaiti"', '"Kaiti SC"', '"楷体"', '"Songti SC"', 'serif'],
|
||||
// 标题与比分:宋体血统,报纸版面的骨架
|
||||
serif: [
|
||||
'Georgia',
|
||||
|
||||
+5
-1
@@ -12,13 +12,17 @@ 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",
|
||||
"httpx>=0.27",
|
||||
"pip-tools>=7.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+1184
File diff suppressed because it is too large
Load Diff
+82
-5
@@ -10,31 +10,93 @@ 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() # 启动安全校验(生产拒绝/开发警告)
|
||||
|
||||
# D7(工程债): 进程内限流(_RateLimiter)与 KeyRing 均为单进程状态;
|
||||
# 多 worker 部署时各进程独立计数,限流阈值会按 worker 数放大、KeyRing 不共享。
|
||||
# 生产环境应将限流前置到 Nginx/网关,或以单 worker 运行(见 src/api/deps.py 注释)。
|
||||
# 此处仅提醒一次,不阻断启动,也不引入 Redis 等外部依赖。
|
||||
if settings.APP_ENV == "production":
|
||||
logger.warning(
|
||||
"APP_ENV=production: 进程内 rate-limit 与 KeyRing 仅单进程有效;"
|
||||
"多 worker 部署请将限流前置到 Nginx/网关,或以单 worker 运行"
|
||||
)
|
||||
|
||||
# 注册默认定时任务(如果数据库中没有)
|
||||
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()]
|
||||
methods = [m.strip() for m in settings.CORS_METHODS.split(",") if m.strip()]
|
||||
headers = [h.strip() for h in settings.CORS_HEADERS.split(",") if h.strip()]
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
allow_methods=methods,
|
||||
allow_headers=headers,
|
||||
)
|
||||
|
||||
from src.api.routes.matches import router as matches_router
|
||||
@@ -42,12 +104,18 @@ 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():
|
||||
@@ -56,14 +124,23 @@ def create_app() -> FastAPI:
|
||||
|
||||
@app.get("/health/ready")
|
||||
async def health_ready():
|
||||
"""就绪检查: 验证数据库连接。"""
|
||||
"""就绪检查:验证数据库连接。
|
||||
|
||||
数据库不可达时返回 HTTP 503,而非 200 + not_ready ——
|
||||
这样 K8s/Compose 的 readinessProbe 才能正确判定「未就绪」并停止流量。
|
||||
"""
|
||||
from src.db.base import engine
|
||||
from fastapi.responses import JSONResponse
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(lambda conn: None)
|
||||
return {"status": "ready"}
|
||||
except Exception:
|
||||
return {"status": "not_ready"}
|
||||
except Exception as e:
|
||||
logger.warning("就绪检查失败(数据库不可达): %s", e)
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={"status": "not_ready", "reason": "database_unreachable"},
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
+177
-21
@@ -1,43 +1,199 @@
|
||||
"""API 依赖:鉴权等横切关注点。
|
||||
|
||||
审查报告 P2-7:ingest / backtest / settle 这类「写入型或高成本」接口此前
|
||||
完全无鉴权 —— 任何能访问到服务的人都可触发采集、或直接烧掉 LLM 额度。
|
||||
|
||||
策略(渐进式,不破坏本地开发):
|
||||
- `ADMIN_API_KEY` 未配置 → 直接放行,并打一次 warning。
|
||||
这样本地 `docker compose up` 无需额外配置即可用。
|
||||
- 已配置 → 必须带匹配的 `X-API-Key` 请求头,否则 401。
|
||||
- 管理员密码:库中 scrypt 哈希优先,回落 .env 初始值;后台可在线修改。
|
||||
- 密码已配置(哈希或 .env)→ 管理后台可用密码登录,登录后颁发 HttpOnly
|
||||
Cookie 会话;受保护接口接受 Cookie 会话或 X-API-Key。
|
||||
- 仅配置 `ADMIN_API_KEY` → 受保护接口只接受 `X-API-Key` 请求头(机器/脚本调用)。
|
||||
- 两者都未配置 → 直接放行,并打一次 warning(本地开发模式)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from fastapi import Header, HTTPException
|
||||
from fastapi import Header, HTTPException, Request
|
||||
|
||||
from src.core.config import settings
|
||||
from src.core.runtime_config import get_admin_password_hash
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_warned_unset = False
|
||||
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()
|
||||
|
||||
用法: `@router.post("/ingest/bzzoiro", dependencies=[Depends(require_admin_key)])`
|
||||
|
||||
async def get_session_secret() -> bytes:
|
||||
"""会话签名密钥 = HMAC(SECRET_KEY, 管理员凭证指纹)。
|
||||
|
||||
指纹来自密码哈希(密码本身永不参与签名):密码变更 → 指纹变化
|
||||
→ 全部旧会话失效,无需额外吊销机制。
|
||||
"""
|
||||
global _warned_unset
|
||||
from src.core.runtime_config import get_admin_credential_fingerprint
|
||||
|
||||
expected = settings.ADMIN_API_KEY
|
||||
if not expected:
|
||||
if not _warned_unset:
|
||||
logger.warning(
|
||||
"ADMIN_API_KEY 未设置,采集/回测接口当前【无鉴权】。"
|
||||
"生产环境请设置该环境变量。"
|
||||
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。"
|
||||
)
|
||||
_warned_unset = True
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="服务未配置管理鉴权,请联系管理员",
|
||||
)
|
||||
# 开发环境 fail-open + warning
|
||||
logger.warning(
|
||||
"管理员密码 / ADMIN_API_KEY 均未设置,管理接口当前【无鉴权】。"
|
||||
"生产环境请至少设置其中一项。"
|
||||
)
|
||||
return
|
||||
|
||||
if not x_api_key or not secrets.compare_digest(x_api_key, expected):
|
||||
raise HTTPException(status_code=401, detail="无效或缺失的 X-API-Key")
|
||||
# 1) Cookie 会话(密码登录颁发)
|
||||
token = request.cookies.get(SESSION_COOKIE)
|
||||
if token and verify_session_token(token, await get_session_secret()):
|
||||
return
|
||||
|
||||
# 2) X-API-Key(机器/脚本调用;key 明文只在内存中,与第三方交互必需)
|
||||
if (
|
||||
settings.ADMIN_API_KEY
|
||||
and x_api_key
|
||||
and secrets.compare_digest(x_api_key, settings.ADMIN_API_KEY)
|
||||
):
|
||||
return
|
||||
|
||||
raise HTTPException(status_code=401, detail="未登录或凭证无效")
|
||||
|
||||
|
||||
# ── 客户端 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))
|
||||
|
||||
@@ -0,0 +1,668 @@
|
||||
"""后台管理路由:数据源配置的查看、修改与连通性测试。
|
||||
|
||||
所有接口需管理员鉴权(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]}
|
||||
@@ -0,0 +1,141 @@
|
||||
"""管理后台认证路由:密码登录 → 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": "密码已修改,请用新密码重新登录"}
|
||||
@@ -6,7 +6,7 @@ import logging
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from src.api.deps import require_admin_key
|
||||
from src.api.deps import require_admin
|
||||
from src.llm.backtest import run_backtest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -23,18 +23,21 @@ class BacktestRequest(BaseModel):
|
||||
model: str | None = Field(None, description="指定模型 (空=默认)")
|
||||
|
||||
|
||||
@router.post("/backtest", dependencies=[Depends(require_admin_key)])
|
||||
@router.post("/backtest", dependencies=[Depends(require_admin)])
|
||||
async def backtest(req: BacktestRequest):
|
||||
"""对历史比赛运行回测。
|
||||
|
||||
该接口会对每场已完赛比赛各发起一次 LLM 预测,成本高 —— 因此需要
|
||||
`X-API-Key` 鉴权(见审查报告 P2-7)。
|
||||
管理员鉴权(require_admin)。
|
||||
|
||||
对每场已完赛比赛:
|
||||
1. 用比赛之前的数据构建上下文 (防未来信息泄漏)
|
||||
2. 调 LLM 预测
|
||||
3. 用实际比分回填
|
||||
1. 用比赛之前的数据构建上下文 (防未来信息泄漏,cutoff=match_date-1天)
|
||||
2. 调 LLM 预测(强制 use_cache=False,避免缓存命中导致反复 settle 同一行)
|
||||
3. 用实际比分回填(settle)
|
||||
4. 统计准确率 / RMSE / 校准度
|
||||
|
||||
限流:单请求上限 200 场(默认 20),避免一次打爆 LLM 额度。
|
||||
回测写入 run_type='backtest',与实盘(live)互不覆盖(唯一键含 run_type)。
|
||||
"""
|
||||
try:
|
||||
summary = await run_backtest(
|
||||
@@ -53,10 +56,11 @@ async def backtest(req: BacktestRequest):
|
||||
"summary": {
|
||||
"total": summary.total,
|
||||
"scored": summary.scored,
|
||||
"success": summary.success,
|
||||
"degraded": summary.degraded,
|
||||
"accuracy_1x2": summary.accuracy_1x2,
|
||||
"avg_score_rmse": summary.avg_score_rmse,
|
||||
"avg_subjective_confidence": summary.avg_subjective_confidence,
|
||||
"calibration": summary.calibration,
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
@@ -64,6 +68,8 @@ async def backtest(req: BacktestRequest):
|
||||
"league_code": r.league_code,
|
||||
"home_team": r.home_team,
|
||||
"away_team": r.away_team,
|
||||
"home_team_zh": r.home_team_zh,
|
||||
"away_team_zh": r.away_team_zh,
|
||||
"match_date": r.match_date,
|
||||
"actual_score": f"{r.actual_home}-{r.actual_away}",
|
||||
"actual_1x2": r.actual_1x2,
|
||||
|
||||
+38
-9
@@ -3,9 +3,9 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from src.api.deps import require_admin_key
|
||||
from src.api.deps import require_admin
|
||||
from src.api.schemas import EvalSummaryOut, SettleRequest
|
||||
from src.db.base import AsyncSession, get_db, get_db_read
|
||||
from src.llm.eval import get_eval_summary, settle_prediction
|
||||
@@ -15,21 +15,50 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/v1", tags=["eval"])
|
||||
|
||||
|
||||
@router.post("/eval/settle", dependencies=[Depends(require_admin_key)])
|
||||
@router.post("/eval/settle", dependencies=[Depends(require_admin)])
|
||||
async def settle(req: SettleRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""回填实际结果。"""
|
||||
"""回填实际结果。
|
||||
|
||||
status 为 degraded/failed 的预测无法结算(返回 400);
|
||||
记录不存在返回 404。
|
||||
"""
|
||||
try:
|
||||
pred = await settle_prediction(req.prediction_id, req.home_goals, req.away_goals)
|
||||
return {"id": pred.id, "settled": pred.settled}
|
||||
except ValueError as e:
|
||||
logger.warning("settle failed: %s", e)
|
||||
msg = str(e)
|
||||
# degraded/failed 拒绝:明确的 400,而非与"未找到"混为一谈
|
||||
if "无法结算" in msg:
|
||||
logger.warning("settle rejected: %s", msg)
|
||||
raise HTTPException(400, msg)
|
||||
logger.warning("settle failed: %s", msg)
|
||||
raise HTTPException(404, "预测记录不存在")
|
||||
except Exception as e:
|
||||
logger.exception("settle error")
|
||||
raise HTTPException(500, "回填失败,请查看服务器日志")
|
||||
|
||||
|
||||
@router.get("/eval/summary", response_model=EvalSummaryOut)
|
||||
async def eval_summary():
|
||||
"""提供商/模型准确率对比。"""
|
||||
return await get_eval_summary()
|
||||
@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,
|
||||
)
|
||||
|
||||
+146
-40
@@ -1,60 +1,166 @@
|
||||
"""采集路由。"""
|
||||
"""采集路由(bzzoiro 单一数据源)。
|
||||
|
||||
任务类型:
|
||||
events — 比赛日程/比分(/events/)
|
||||
standings — 联赛积分榜(/leagues/{id}/standings/)
|
||||
stats — 已完赛比赛详细统计回填(/events/{id}/stats/)
|
||||
all — 依次执行以上三项
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.api.deps import require_admin_key
|
||||
from src.api.schemas import IngestBzzoiroRequest, IngestResponse, IngestUnderstatRequest, IngestInjuriesRequest, IngestSimpleResponse
|
||||
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.data.sources import get_source
|
||||
from src.data.injuries import ingest_injuries
|
||||
from src.db.models import IngestJob
|
||||
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()
|
||||
|
||||
@router.post("/ingest/bzzoiro", response_model=IngestResponse, dependencies=[Depends(require_admin_key)])
|
||||
VALID_TASKS = {"events", "standings", "stats", "all"}
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
async def _update_job(job_id: str, **fields) -> None:
|
||||
"""更新采集任务状态(尽力而为:失败只记日志,绝不拖垮采集主流程)。
|
||||
|
||||
job 记录是任务级可观测性,IngestFailure 死信是行级失败记录,
|
||||
两者独立工作 —— 本函数抛错不应影响采集结果,故整体吞异常。
|
||||
"""
|
||||
try:
|
||||
async with get_uow() as session:
|
||||
stmt = select(IngestJob).where(IngestJob.id == job_id)
|
||||
job = (await session.execute(stmt)).scalar_one_or_none()
|
||||
if job is None:
|
||||
logger.warning("ingest job %s 不存在,跳过状态更新(%s)", job_id, fields)
|
||||
return
|
||||
for k, v in fields.items():
|
||||
setattr(job, k, v)
|
||||
except Exception:
|
||||
logger.warning("ingest job %s 状态更新失败(不影响采集): %s", job_id, fields, exc_info=True)
|
||||
|
||||
|
||||
def _spawn(coro) -> None:
|
||||
"""启动后台采集任务;异常已在任务内记录到系统日志。"""
|
||||
task = asyncio.create_task(coro)
|
||||
_background_tasks.add(task)
|
||||
task.add_done_callback(_background_tasks.discard)
|
||||
|
||||
|
||||
@router.post("/ingest/bzzoiro", dependencies=[Depends(require_admin)])
|
||||
async def ingest_bzzoiro_route(req: IngestBzzoiroRequest):
|
||||
"""触发 bzzoiro 采集。"""
|
||||
source = get_source("bzzoiro")
|
||||
"""触发 bzzoiro 采集(events / standings / stats / all)。
|
||||
|
||||
启动后台任务前先创建 ingest_jobs 记录(pending),响应返回 job_id,
|
||||
供 admin 通过 GET /api/v1/admin/ingest/jobs/{job_id} 轮询状态。
|
||||
"""
|
||||
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]
|
||||
|
||||
# 任务级可观测性:先落 pending 记录,后台 _run_bzzoiro 接管状态流转
|
||||
job = IngestJob(
|
||||
task=req.task,
|
||||
params={
|
||||
"leagues": leagues,
|
||||
"date_from": req.date_from,
|
||||
"date_to": req.date_to,
|
||||
"status": req.status,
|
||||
"limit": req.limit,
|
||||
"season": req.season,
|
||||
},
|
||||
status="pending",
|
||||
)
|
||||
async with get_uow() as session:
|
||||
session.add(job)
|
||||
|
||||
_spawn(_run_bzzoiro(job.id, req.task, leagues, req))
|
||||
return {
|
||||
"ok": True,
|
||||
"job_id": job.id,
|
||||
"message": f"采集任务已启动(后台执行,任务: {task_label}),可用 job_id 查询状态或在「系统日志」查看进度",
|
||||
}
|
||||
|
||||
|
||||
async def _run_bzzoiro(job_id: str, task: str, leagues: list[str], req: IngestBzzoiroRequest) -> None:
|
||||
"""后台执行 bzzoiro 采集:上游限速时单次可能耗时数分钟,必须脱离请求生命周期。
|
||||
|
||||
同时维护 ingest_jobs 状态(pending → running → success/failed):
|
||||
- result 存各子任务统计摘要(errors 截断到前 10 条)
|
||||
- 状态更新经 _update_job 尽力而为,失败不影响采集本身
|
||||
- 单条管线抓取失败的行级记录仍由 bzzoiro 死信逻辑(IngestFailure)负责
|
||||
"""
|
||||
await _update_job(job_id, status="running", started_at=_utcnow())
|
||||
summary: dict = {}
|
||||
try:
|
||||
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 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,
|
||||
)
|
||||
return IngestResponse(**result)
|
||||
except Exception as e:
|
||||
logger.exception("bzzoiro ingest failed")
|
||||
raise HTTPException(500, "数据采集失败,请查看服务器日志")
|
||||
if merged["errors"]:
|
||||
logger.warning("bzzoiro 比赛采集错误 %d 条: %s", len(merged["errors"]), merged["errors"][:3])
|
||||
summary["events"] = {**merged, "errors": merged["errors"][:10]}
|
||||
|
||||
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"])
|
||||
summary["standings"] = {k: r.get(k) for k in ("total_upserted", "errors") if k in r}
|
||||
if isinstance(summary["standings"].get("errors"), list):
|
||||
summary["standings"]["errors"] = summary["standings"]["errors"][:10]
|
||||
|
||||
@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 回填失败,请查看服务器日志")
|
||||
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])
|
||||
summary["stats"] = {k: r.get(k) for k in ("total_inserted", "total_updated", "errors") if k in r}
|
||||
if isinstance(summary["stats"].get("errors"), list):
|
||||
summary["stats"]["errors"] = summary["stats"]["errors"][:10]
|
||||
except Exception as exc:
|
||||
logger.exception("bzzoiro 采集任务失败(task=%s)", task)
|
||||
await _update_job(job_id, status="failed", finished_at=_utcnow(), error=repr(exc))
|
||||
return
|
||||
|
||||
|
||||
@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, "伤停采集失败,请查看服务器日志")
|
||||
await _update_job(job_id, status="success", finished_at=_utcnow(), result=summary)
|
||||
|
||||
+231
-10
@@ -4,18 +4,36 @@ from __future__ import annotations
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from src.api.schemas import MatchListOut, MatchOut
|
||||
from src.api.schemas import MatchListOut, MatchOut, PredictionOut
|
||||
from src.db.base import AsyncSession, get_db_read
|
||||
from src.db.models import League, Match
|
||||
from src.db.models import League, Match, Prediction, Standing
|
||||
|
||||
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])
|
||||
async def list_leagues(db: AsyncSession = Depends(get_db_read)):
|
||||
"""联赛列表(公开只读,P1-3: 公开站联赛筛选需要;仅返回展示字段)。"""
|
||||
stmt = select(League).order_by(League.name)
|
||||
result = await db.execute(stmt)
|
||||
leagues = result.scalars().all()
|
||||
@@ -40,10 +58,19 @@ 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)
|
||||
q = q.where(
|
||||
(Match.match_date < last_date) |
|
||||
((Match.match_date == last_date) & (Match.id < last_id))
|
||||
)
|
||||
# 游标方向必须与排序方向一致:
|
||||
# - 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))
|
||||
)
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
|
||||
@@ -62,9 +89,20 @@ async def list_matches(
|
||||
d = datetime.strptime(date, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
raise HTTPException(400, "date 格式应为 YYYY-MM-DD")
|
||||
q = q.where(Match.match_date >= d, Match.match_date < d + timedelta(days=1))
|
||||
# date 是用户本地日期(默认北京 UTC+8);match_date 存 UTC,需转换:
|
||||
# 本地 00:00 (UTC+8) = UTC 前一天 16:00;本地 24:00 = UTC 当天 16:00
|
||||
from datetime import timezone as tz_mod
|
||||
tz_cn = tz_mod(timedelta(hours=8))
|
||||
local_start = d.replace(tzinfo=tz_cn)
|
||||
local_end = local_start + timedelta(days=1)
|
||||
q = q.where(Match.match_date >= local_start, Match.match_date < local_end)
|
||||
|
||||
rows = (await db.execute(q.order_by(Match.match_date.desc(), Match.id.desc()).limit(limit + 1))).scalars().all()
|
||||
# 未开赛按日期正序(最近的排最前,便于预测);其余按日期倒序(最新赛果在前)
|
||||
if status == "scheduled":
|
||||
order = (Match.match_date.asc(), Match.id.asc())
|
||||
else:
|
||||
order = (Match.match_date.desc(), Match.id.desc())
|
||||
rows = (await db.execute(q.order_by(*order).limit(limit + 1))).scalars().all()
|
||||
has_more = len(rows) > limit
|
||||
rows = rows[:limit]
|
||||
|
||||
@@ -99,12 +137,26 @@ async def list_matches(
|
||||
async def get_match(match_id: int, db: AsyncSession = Depends(get_db_read)):
|
||||
stmt = (
|
||||
select(Match)
|
||||
.options(selectinload(Match.league), selectinload(Match.home_team), selectinload(Match.away_team))
|
||||
.options(
|
||||
selectinload(Match.league),
|
||||
selectinload(Match.home_team),
|
||||
selectinload(Match.away_team),
|
||||
selectinload(Match.stats),
|
||||
)
|
||||
.where(Match.id == match_id)
|
||||
)
|
||||
m = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if m is None:
|
||||
raise HTTPException(404, "match not found")
|
||||
# 最近预测(倒序,最多 5 条)——复用 PredictionOut 结构,只读,不触发 LLM
|
||||
preds = (
|
||||
await db.execute(
|
||||
select(Prediction)
|
||||
.where(Prediction.match_id == match_id)
|
||||
.order_by(Prediction.created_at.desc())
|
||||
.limit(5)
|
||||
)
|
||||
).scalars().all()
|
||||
return MatchOut(
|
||||
id=m.id,
|
||||
league_code=m.league.code if m.league else None,
|
||||
@@ -120,4 +172,173 @@ 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")
|
||||
async def match_context(match_id: int, db: AsyncSession = Depends(get_db_read)):
|
||||
"""比赛上下文(公开只读,P1-2: 公开站详情页需要;不触发 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())}
|
||||
|
||||
+118
-15
@@ -1,15 +1,21 @@
|
||||
"""预测路由。"""
|
||||
"""预测路由。
|
||||
|
||||
安全改进:
|
||||
- 限流: 每分钟 10 次 / IP(内存实现)
|
||||
- DB 连接: 短 session 模式,LLM 调用期间不持有连接
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
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, get_db_read
|
||||
from src.db.models import Prediction
|
||||
from src.db.base import AsyncSession, get_db_read, short_read
|
||||
from src.db.models import Match, Prediction
|
||||
from src.llm.predict import predict_match, PredictResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -17,9 +23,26 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/v1", tags=["predict"])
|
||||
|
||||
|
||||
@router.post("/predict", response_model=PredictOut)
|
||||
async def predict(req: PredictRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""对一场比赛调 LLM 预测。mode=multi(默认,5专家+终裁)或 single。"""
|
||||
@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 连接)
|
||||
try:
|
||||
result = await predict_match(
|
||||
req.match_id,
|
||||
@@ -28,6 +51,9 @@ async def predict(req: PredictRequest, db: AsyncSession = Depends(get_db)):
|
||||
mode=req.mode,
|
||||
)
|
||||
except ValueError as e:
|
||||
msg = str(e)
|
||||
if "已结算" in msg:
|
||||
raise HTTPException(409, msg)
|
||||
logger.warning("predict validation error: %s", e)
|
||||
raise HTTPException(404, "比赛不存在")
|
||||
except RuntimeError as e:
|
||||
@@ -37,26 +63,75 @@ async def predict(req: PredictRequest, db: AsyncSession = Depends(get_db)):
|
||||
logger.exception("predict unexpected error")
|
||||
raise HTTPException(500, "预测失败,请查看服务器日志")
|
||||
|
||||
# single / multi 两种结果统一映射
|
||||
# D2: 三种模式统一返回 PredictResult —— 字段映射单一化,无 dict 分支。
|
||||
# 仅 baseline 的 prediction_id 需要在此落库补齐(服务层不落库)。
|
||||
if req.mode == "baseline":
|
||||
prediction_id = await _persist_baseline(req.match_id, result)
|
||||
else:
|
||||
prediction_id = result.prediction_id
|
||||
|
||||
# 3. 结果映射(无 DB 访问)
|
||||
logger.info(
|
||||
"预测完成 match=%s mode=%s pred=%s:%s (%s)",
|
||||
req.match_id, req.mode,
|
||||
result.pred_home_goals, result.pred_away_goals, result.pred_1x2,
|
||||
)
|
||||
|
||||
return PredictOut(
|
||||
prediction_id=result.prediction_id,
|
||||
prediction_id=prediction_id,
|
||||
provider=result.provider,
|
||||
model=result.model,
|
||||
prompt_version=getattr(result, "prompt_version", None),
|
||||
mode=getattr(result, "mode", "single"),
|
||||
prompt_version=result.prompt_version,
|
||||
mode=req.mode,
|
||||
pred_home_goals=result.pred_home_goals,
|
||||
pred_away_goals=result.pred_away_goals,
|
||||
alt_pred_home_goals=result.alt_pred_home_goals,
|
||||
alt_pred_away_goals=result.alt_pred_away_goals,
|
||||
pred_1x2=result.pred_1x2,
|
||||
subjective_confidence=result.subjective_confidence,
|
||||
reasoning=result.reasoning,
|
||||
agent_outputs=getattr(result, "agent_outputs", None),
|
||||
agent_weights=getattr(result, "agent_weights", None),
|
||||
status=result.status,
|
||||
agent_outputs=result.agent_outputs,
|
||||
agent_weights=result.agent_weights,
|
||||
context=result.context,
|
||||
latency_ms=result.latency_ms,
|
||||
prompt_tokens=result.prompt_tokens,
|
||||
completion_tokens=result.completion_tokens,
|
||||
rate_limit_remaining=get_predict_rate_limit_remaining(request),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/predictions", response_model=list[PredictionOut])
|
||||
async def _persist_baseline(match_id: int, baseline: PredictResult) -> 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.prompt_version,
|
||||
"prompt_tokens": baseline.prompt_tokens or 0,
|
||||
"completion_tokens": baseline.completion_tokens or 0,
|
||||
"latency_ms": baseline.latency_ms or 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.raw,
|
||||
"status": "success",
|
||||
},
|
||||
)
|
||||
return pred.id
|
||||
|
||||
|
||||
@router.get("/predictions", response_model=list[PredictionOut], dependencies=[Depends(require_admin)])
|
||||
async def list_predictions(
|
||||
match_id: int | None = None,
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
@@ -77,20 +152,44 @@ 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
|
||||
]
|
||||
|
||||
|
||||
@router.get("/predictions/{prediction_id}", response_model=PredictionOut)
|
||||
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)])
|
||||
async def get_prediction(prediction_id: int, db: AsyncSession = Depends(get_db_read)):
|
||||
p = await db.get(Prediction, prediction_id)
|
||||
if p is None:
|
||||
@@ -104,10 +203,14 @@ async def get_prediction(prediction_id: int, db: AsyncSession = Depends(get_db_r
|
||||
mode=p.mode or "single",
|
||||
pred_home_goals=p.pred_home_goals,
|
||||
pred_away_goals=p.pred_away_goals,
|
||||
alt_pred_home_goals=p.alt_pred_home_goals,
|
||||
alt_pred_away_goals=p.alt_pred_away_goals,
|
||||
pred_1x2=p.pred_1x2,
|
||||
subjective_confidence=p.subjective_confidence,
|
||||
reasoning=p.reasoning,
|
||||
status=p.status or "success",
|
||||
agent_outputs=p.agent_outputs,
|
||||
agent_weights=p.agent_weights,
|
||||
created_at=p.created_at,
|
||||
actual_home_goals=p.actual_home_goals,
|
||||
actual_away_goals=p.actual_away_goals,
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
"""定时任务管理路由。"""
|
||||
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} 次)"}
|
||||
|
||||
|
||||
# ── 采集任务状态(ingest_jobs) ──────────────────────────────────
|
||||
|
||||
|
||||
def _serialize_job(j) -> dict:
|
||||
"""ingest_jobs 行 → 响应 dict(时间统一 isoformat)。"""
|
||||
return {
|
||||
"id": j.id,
|
||||
"task": j.task,
|
||||
"params": j.params,
|
||||
"status": j.status,
|
||||
"result": j.result,
|
||||
"error": j.error,
|
||||
"created_at": j.created_at.isoformat() if j.created_at else None,
|
||||
"started_at": j.started_at.isoformat() if j.started_at else None,
|
||||
"finished_at": j.finished_at.isoformat() if j.finished_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/ingest/jobs")
|
||||
async def list_ingest_jobs(
|
||||
limit: int = 20,
|
||||
status: str | None = None,
|
||||
db: AsyncSession = Depends(get_db_read),
|
||||
):
|
||||
"""列出采集任务状态(最新在前),可按 status 过滤。"""
|
||||
from src.db.models import IngestJob
|
||||
|
||||
stmt = select(IngestJob).order_by(IngestJob.created_at.desc()).limit(max(1, min(limit, 100)))
|
||||
if status:
|
||||
stmt = stmt.where(IngestJob.status == status)
|
||||
rows = (await db.execute(stmt)).scalars().all()
|
||||
return [_serialize_job(j) for j in rows]
|
||||
|
||||
|
||||
@router.get("/ingest/jobs/{job_id}")
|
||||
async def get_ingest_job(job_id: str, db: AsyncSession = Depends(get_db_read)):
|
||||
"""查询单次采集任务的状态与结果(供前端提交后轮询)。"""
|
||||
from src.db.models import IngestJob
|
||||
|
||||
stmt = select(IngestJob).where(IngestJob.id == job_id)
|
||||
job = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if job is None:
|
||||
raise HTTPException(404, "采集任务不存在")
|
||||
return _serialize_job(job)
|
||||
+57
-15
@@ -1,7 +1,7 @@
|
||||
"""Pydantic schemas。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import date, datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -29,6 +29,10 @@ 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):
|
||||
@@ -42,7 +46,14 @@ class PredictRequest(BaseModel):
|
||||
provider: str | None = None
|
||||
model: str | None = None
|
||||
prompt_version: str | None = None
|
||||
mode: str = "multi" # multi(默认, 5专家+终裁) | single(单次调用)
|
||||
mode: str = Field(
|
||||
"multi",
|
||||
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,用于回测防未来信息")
|
||||
|
||||
|
||||
class PredictOut(BaseModel):
|
||||
@@ -53,9 +64,18 @@ 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
|
||||
@@ -71,21 +91,30 @@ 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(..., description="联赛代码列表,如 ['E0','SP1']")
|
||||
leagues: list[str] = Field(default_factory=list, description="联赛代码列表,如 ['E0','SP1'];空 = 全部已知联赛")
|
||||
date_from: str | None = None
|
||||
date_to: str | None = None
|
||||
status: str = "finished"
|
||||
status: str | None = Field(None, description="finished/scheduled;空 = 两者都采集")
|
||||
task: str = Field("events", description="采集任务: events(比赛)/standings(积分榜)/stats(统计回填)/all")
|
||||
limit: int = Field(100, ge=1, le=500, description="stats 回填单次最大比赛数")
|
||||
season: str | None = Field(None, description="standings 赛季,如 '2026-2027';空 = 当前赛季")
|
||||
|
||||
|
||||
class IngestResponse(BaseModel):
|
||||
@@ -95,21 +124,29 @@ class IngestResponse(BaseModel):
|
||||
errors: list[str] = []
|
||||
|
||||
|
||||
class IngestUnderstatRequest(BaseModel):
|
||||
league: str = Field(..., description="联赛代码,如 'E0'")
|
||||
season: int = Field(..., description="赛季起始年,如 2025 表示 2025-2026 赛季")
|
||||
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 IngestInjuriesRequest(BaseModel):
|
||||
date: str | None = Field(None, description="日期 YYYY-MM-DD,为空则采集当天")
|
||||
class ScheduleUpdate(BaseModel):
|
||||
task: str | None = None
|
||||
cron: str | None = None
|
||||
leagues: list[str] | None = None
|
||||
enabled: bool | None = None
|
||||
|
||||
|
||||
class IngestSimpleResponse(BaseModel):
|
||||
count: int = 0
|
||||
updated: int = 0
|
||||
skipped: int = 0
|
||||
unmatched: int = 0
|
||||
errors: list[str] = []
|
||||
class ScheduleOut(BaseModel):
|
||||
id: str
|
||||
task: str
|
||||
cron: str
|
||||
leagues: str | None
|
||||
enabled: bool
|
||||
last_run_at: str | None
|
||||
last_status: str | None
|
||||
|
||||
|
||||
class SettleRequest(BaseModel):
|
||||
@@ -120,3 +157,8 @@ 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
|
||||
|
||||
+34
-4
@@ -11,6 +11,9 @@ 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"
|
||||
@@ -28,16 +31,43 @@ class Settings(BaseSettings):
|
||||
# --- data sources ---
|
||||
BZZOIRO_KEY: str = ""
|
||||
BZZOIRO_BASE: str = "https://sports.bzzoiro.com/api/v2"
|
||||
API_FOOTBALL_KEY: str = ""
|
||||
|
||||
# --- 代理头信任 ---
|
||||
# 为 True 时才解析 X-Forwarded-For,否则只用 request.client.host。
|
||||
# 公网部署时应设为 True,并确保仅 Nginx 等可信反代能访问 API,
|
||||
# 且 Nginx 层已覆盖真实 IP(X-Real-IP / proxy_protocol)。
|
||||
TRUST_PROXY_HEADERS: bool = False
|
||||
|
||||
# --- CORS ---
|
||||
CORS_ORIGINS: str = "http://localhost:5173,http://localhost:3000"
|
||||
CORS_METHODS: str = "GET,POST,PUT,DELETE,OPTIONS"
|
||||
CORS_HEADERS: str = "Authorization,Content-Type,X-API-Key,Accept"
|
||||
|
||||
# --- HTTP ---
|
||||
HTTP_DEFAULT_TIMEOUT: int = 30
|
||||
|
||||
# --- database pool ---
|
||||
DB_POOL_SIZE: int = 5
|
||||
DB_MAX_OVERFLOW: int = 10
|
||||
DB_POOL_TIMEOUT: int = 30
|
||||
DB_POOL_RECYCLE: int = 1800
|
||||
|
||||
# --- 管理接口鉴权 ---
|
||||
# 采集 / 回测等高成本或写入型接口需要此 Key(请求头 X-API-Key)。
|
||||
# 留空表示「未启用鉴权」(本地开发默认),生产环境必须设置。
|
||||
# 见审查报告 P2-7:ingest/backtest 无鉴权可被任意调用并烧掉 LLM 额度。
|
||||
# 管理后台登录密码(POST /api/v1/auth/login),登录后颁发 HttpOnly Cookie 会话。
|
||||
# 采集 / 回测等高成本或写入型接口同样需要此密码或下方 API Key。
|
||||
# 两者均留空表示「未启用鉴权」(本地开发默认),生产环境必须至少设置一项。
|
||||
# 注意:.env 中的 ADMIN_PASSWORD 是初始值;后台修改密码后以数据库中的
|
||||
# scrypt 哈希为准,建议随后删除此明文项。
|
||||
ADMIN_PASSWORD: str = ""
|
||||
ADMIN_API_KEY: str = ""
|
||||
# 管理后台会话有效期(小时)
|
||||
ADMIN_SESSION_TTL_HOURS: int = 168
|
||||
|
||||
# --- 加密主密钥 ---
|
||||
# 敏感配置(数据源/LLM 的 API Key)入库加密、会话签名都由它派生。
|
||||
# 只存于部署机 .env,切勿入库或提交代码。生成: openssl rand -base64 32
|
||||
# 变更后已加密配置将无法解密(需在后台重新保存)。
|
||||
SECRET_KEY: str = ""
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"""安全原语:对称加密(Fernet/AES)与密码哈希(scrypt)。
|
||||
|
||||
- API Key 等需要原文调用的敏感值:入库前用 SECRET_KEY 派生的 Fernet 密钥加密,
|
||||
存储格式 `enc:v1:<token>`;读取时解密。SECRET_KEY 只存于部署机 .env,不入库。
|
||||
- 管理员密码:只存 scrypt 哈希(单向,不可逆),验证用,永远不需要还原原文。
|
||||
|
||||
`enc:v1:` 前缀 + 透传设计使旧明文数据无需停机即可共存,由启动迁移一次性加密。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac as _hmac
|
||||
import logging
|
||||
import secrets
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
from src.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ENC_PREFIX = "enc:v1:"
|
||||
|
||||
# scrypt 参数(OWASP 推荐: n=2^17 更强,取 n=2^15 平衡 NAS CPU)
|
||||
_SCRYPT_N = 2**15
|
||||
_SCRYPT_R = 8
|
||||
_SCRYPT_P = 1
|
||||
# OpenSSL 默认 maxmem 限制约 32MB,显式放宽到 128MB
|
||||
_SCRYPT_MAXMEM = 128 * 1024 * 1024
|
||||
|
||||
|
||||
def _fernet() -> Fernet:
|
||||
"""由 SECRET_KEY 确定性派生 Fernet 密钥(任意字符串输入均可)。
|
||||
|
||||
SECRET_KEY 未配置时回落派生自 DATABASE_URL(仅为不让开发环境崩溃;
|
||||
生产必须显式配置,否则加密强度受限 —— 启动时会打 warning)。
|
||||
"""
|
||||
raw = settings.SECRET_KEY
|
||||
if not raw:
|
||||
logger.warning(
|
||||
"SECRET_KEY 未设置,加密密钥回落派生自 DATABASE_URL。"
|
||||
"请在 .env 配置强随机 SECRET_KEY(openssl rand -base64 32)。"
|
||||
)
|
||||
raw = f"fallback:{settings.DATABASE_URL}"
|
||||
digest = hashlib.sha256(raw.encode()).digest()
|
||||
return Fernet(base64.urlsafe_b64encode(digest))
|
||||
|
||||
|
||||
def encrypt_value(plaintext: str) -> str:
|
||||
"""加密敏感值,带版本前缀;空值原样返回。"""
|
||||
if not plaintext:
|
||||
return plaintext
|
||||
token = _fernet().encrypt(plaintext.encode()).decode()
|
||||
return f"{_ENC_PREFIX}{token}"
|
||||
|
||||
|
||||
def decrypt_value(stored: str) -> str:
|
||||
"""解密 `enc:v1:` 前缀的值;无前缀(旧明文)原样返回,便于平滑迁移。"""
|
||||
if not stored or not stored.startswith(_ENC_PREFIX):
|
||||
return stored
|
||||
token = stored[len(_ENC_PREFIX):]
|
||||
try:
|
||||
return _fernet().decrypt(token.encode()).decode()
|
||||
except InvalidToken:
|
||||
# 密钥不匹配(通常是 SECRET_KEY 变了):报错而非静默返回错误数据
|
||||
raise ValueError(
|
||||
"敏感配置解密失败:SECRET_KEY 与加密时不一致。"
|
||||
"恢复原 SECRET_KEY 或在后台重新保存对应配置项。"
|
||||
) from None
|
||||
|
||||
|
||||
def is_encrypted(stored: str) -> bool:
|
||||
return bool(stored) and stored.startswith(_ENC_PREFIX)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""scrypt 哈希,存储格式 scrypt$N$r$p$salt_hex$dk_hex。"""
|
||||
salt = secrets.token_bytes(16)
|
||||
dk = hashlib.scrypt(
|
||||
password.encode(), salt=salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P,
|
||||
dklen=32, maxmem=_SCRYPT_MAXMEM,
|
||||
)
|
||||
return f"scrypt${_SCRYPT_N}${_SCRYPT_R}${_SCRYPT_P}${salt.hex()}${dk.hex()}"
|
||||
|
||||
|
||||
def verify_password(password: str, stored: str) -> bool:
|
||||
"""校验密码与存储的 scrypt 哈希是否匹配。"""
|
||||
try:
|
||||
algo, n, r, p, salt_hex, dk_hex = stored.split("$")
|
||||
if algo != "scrypt":
|
||||
return False
|
||||
dk = hashlib.scrypt(
|
||||
password.encode(),
|
||||
salt=bytes.fromhex(salt_hex),
|
||||
n=int(n),
|
||||
r=int(r),
|
||||
p=int(p),
|
||||
dklen=len(bytes.fromhex(dk_hex)),
|
||||
maxmem=_SCRYPT_MAXMEM,
|
||||
)
|
||||
return _hmac.compare_digest(dk, bytes.fromhex(dk_hex))
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
@@ -2,24 +2,25 @@
|
||||
|
||||
使用方:
|
||||
- src/llm/provider.py: LLM 调用
|
||||
- src/data/understat.py: xG 抓取
|
||||
- src/data/injuries.py: 伤停抓取
|
||||
- src/data/bzzoiro.py: bzzoiro 比赛数据 / 积分榜 / 事件统计
|
||||
|
||||
生命周期由 FastAPI lifespan 管理(关闭时 aclose)。
|
||||
调用方可通过 `timeout` 参数覆盖 per-request 超时。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from src.core.config import settings
|
||||
|
||||
_shared_client: httpx.AsyncClient | None = None
|
||||
_default_timeout = 30
|
||||
|
||||
|
||||
def get_client() -> httpx.AsyncClient:
|
||||
"""获取共享客户端(懒初始化)。"""
|
||||
global _shared_client
|
||||
if _shared_client is None or _shared_client.is_closed:
|
||||
_shared_client = httpx.AsyncClient(timeout=_default_timeout)
|
||||
_shared_client = httpx.AsyncClient(timeout=settings.HTTP_DEFAULT_TIMEOUT)
|
||||
return _shared_client
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""内存日志缓冲:供后台「系统日志」页查看应用运行日志。
|
||||
|
||||
把应用日志(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))
|
||||
@@ -1,89 +0,0 @@
|
||||
"""重试工具:带指数退避的瞬态错误重试。
|
||||
|
||||
NOTE(审查报告 P3):当前全项目**无调用点** —— bzzoiro 在 `_fetch_json_sync`
|
||||
里自带了一套重试逻辑,understat/injuries 各自也有。这里保留是作为后续统一
|
||||
重试策略的落点,但请勿误以为它已在生效。
|
||||
|
||||
如果决定不引入统一重试,建议删除本文件以避免"看起来有重试、实际没有"的误判。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
from typing import Callable, Iterable, TypeVar
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def with_retry(
|
||||
*,
|
||||
max_retries: int = 3,
|
||||
base_delay: float = 1.0,
|
||||
max_delay: float = 30.0,
|
||||
retryable_exceptions: Iterable[type[BaseException]] = (Exception,),
|
||||
on_retry: Callable[[Exception, int], None] | None = None,
|
||||
) -> Callable:
|
||||
"""重试装饰器(同步/异步通用,指数退避 + 抖动)。
|
||||
|
||||
Args:
|
||||
max_retries: 最大重试次数
|
||||
base_delay: 基础延迟(秒)
|
||||
max_delay: 最大延迟(秒)
|
||||
retryable_exceptions: 触发重试的异常类型
|
||||
on_retry: 重试回调(exception, attempt)
|
||||
"""
|
||||
retryable = tuple(retryable_exceptions)
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@functools.wraps(func)
|
||||
async def async_wrapper(*args, **kwargs):
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(max_retries + 1):
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
except retryable as e:
|
||||
last_exc = e
|
||||
if attempt == max_retries:
|
||||
break
|
||||
delay = min(base_delay * (2 ** attempt), max_delay)
|
||||
delay += random.uniform(0, delay * 0.1) # 抖动
|
||||
logger.warning(
|
||||
"%s failed (attempt %d/%d), retry in %.1fs: %s",
|
||||
func.__name__, attempt + 1, max_retries, delay, e,
|
||||
)
|
||||
if on_retry:
|
||||
on_retry(e, attempt + 1)
|
||||
await asyncio.sleep(delay)
|
||||
raise last_exc # type: ignore[misc]
|
||||
|
||||
@functools.wraps(func)
|
||||
def sync_wrapper(*args, **kwargs):
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(max_retries + 1):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except retryable as e:
|
||||
last_exc = e
|
||||
if attempt == max_retries:
|
||||
break
|
||||
delay = min(base_delay * (2 ** attempt), max_delay)
|
||||
delay += random.uniform(0, delay * 0.1)
|
||||
logger.warning(
|
||||
"%s failed (attempt %d/%d), retry in %.1fs: %s",
|
||||
func.__name__, attempt + 1, max_retries, delay, e,
|
||||
)
|
||||
if on_retry:
|
||||
on_retry(e, attempt + 1)
|
||||
time.sleep(delay)
|
||||
raise last_exc # type: ignore[misc]
|
||||
|
||||
if asyncio.iscoroutinefunction(func):
|
||||
return async_wrapper
|
||||
return sync_wrapper
|
||||
|
||||
return decorator
|
||||
@@ -0,0 +1,268 @@
|
||||
"""运行时配置:数据库优先,回落 .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
|
||||
@@ -0,0 +1,252 @@
|
||||
"""定时任务调度器:支持 cron 表达式触发采集任务。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Callable, Coroutine
|
||||
|
||||
from croniter import croniter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ScheduledTask:
|
||||
"""一个定时任务。"""
|
||||
|
||||
#: 运行循环的轮询粒度(秒)。同时决定运行期 cron/next_run 变更的生效延迟上限。
|
||||
SLEEP_TICK: float = 1.0
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
task_id: str,
|
||||
cron: str,
|
||||
fn: Callable[[], Coroutine],
|
||||
enabled: bool = True,
|
||||
) -> None:
|
||||
self.task_id = task_id
|
||||
self.cron = cron
|
||||
self.fn = fn
|
||||
self.enabled = enabled
|
||||
self.last_run: datetime | None = None
|
||||
self.next_run: datetime | None = None
|
||||
self._task: asyncio.Task | None = None
|
||||
# 构造时立即校验 cron:非法表达式直接报错,不留给 _run_loop 静默吞掉。
|
||||
# (全量审查 C1: 传入任务类型字符串时 croniter 抛异常被吞 → 任务永不触发)
|
||||
self._calc_next(raise_on_error=True)
|
||||
|
||||
def _calc_next(self, *, raise_on_error: bool = False) -> None:
|
||||
"""计算下次运行时间。
|
||||
|
||||
raise_on_error=True 时非法 cron 抛 ValueError(构造期用);
|
||||
否则仅告警并置 next_run=None(运行期容错)。
|
||||
"""
|
||||
try:
|
||||
# 必须调用 datetime.now():传方法对象本身会让 croniter 在
|
||||
# (start_time or now) 的算术里抛 TypeError,导致 next_run 永远为 None。
|
||||
self.next_run = croniter(self.cron, datetime.now()).get_next(datetime)
|
||||
except Exception as e:
|
||||
self.next_run = None
|
||||
msg = (
|
||||
f"任务 {self.task_id!r} 的 cron 表达式非法: {self.cron!r} ({e})。"
|
||||
"注意: 此处应为 cron 表达式(如 '0 8 * * *'),不是任务类型名。"
|
||||
)
|
||||
if raise_on_error:
|
||||
raise ValueError(msg) from e
|
||||
logger.error(msg)
|
||||
|
||||
def update(self, cron: str | None = None, enabled: bool | None = None) -> None:
|
||||
if cron is not None:
|
||||
self.cron = cron
|
||||
if enabled is not None:
|
||||
self.enabled = enabled
|
||||
self._calc_next()
|
||||
|
||||
async def _run_loop(self) -> None:
|
||||
"""任务运行循环。
|
||||
|
||||
睡眠策略: 使用固定的短 tick(SLEEP_TICK 秒)轮询 next_run,而不是
|
||||
一次性 sleep 到 next_run。原因是运行期可通过 API 更新 cron / 手动
|
||||
调整 next_run;若按 wait_seconds 长时间沉睡,变更最长要等
|
||||
wait_seconds 才生效(实测可达 60s),表现为「改了不生效」。
|
||||
"""
|
||||
while True:
|
||||
try:
|
||||
if not self.enabled or not self.next_run:
|
||||
await asyncio.sleep(self.SLEEP_TICK)
|
||||
self._calc_next()
|
||||
continue
|
||||
|
||||
now = datetime.now()
|
||||
wait_seconds = (self.next_run - now).total_seconds()
|
||||
if wait_seconds > 0:
|
||||
# 短 tick 轮询,保证 next_run/cron 变更能及时被感知
|
||||
await asyncio.sleep(min(wait_seconds, self.SLEEP_TICK))
|
||||
continue
|
||||
|
||||
# 执行任务:先推进 next_run 再执行,避免任务耗时导致重复触发
|
||||
self.last_run = datetime.now()
|
||||
self._calc_next()
|
||||
try:
|
||||
logger.info("定时任务触发: %s (cron=%s)", self.task_id, self.cron)
|
||||
await self.fn()
|
||||
logger.info("定时任务完成: %s", self.task_id)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
# 单个任务失败不能终止循环,否则一次异常即永久停摆
|
||||
logger.exception("定时任务失败: %s", self.task_id)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
# 循环体自身的意外异常(如 _calc_next)也不能终止调度
|
||||
logger.exception("调度循环异常: %s", self.task_id)
|
||||
await asyncio.sleep(self.SLEEP_TICK)
|
||||
|
||||
|
||||
class DataQualityScheduler:
|
||||
"""数据质量检查调度器(独立于采集任务)。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._task: asyncio.Task | None = None
|
||||
self._running = False
|
||||
|
||||
async def start(self) -> None:
|
||||
self._running = True
|
||||
self._task = asyncio.create_task(self._run_loop())
|
||||
logger.info("数据质量检查调度器已启动")
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._running = False
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
|
||||
async def _run_loop(self) -> None:
|
||||
"""每小时执行一次数据质量检查。"""
|
||||
while self._running:
|
||||
try:
|
||||
await self._run_checks()
|
||||
except Exception:
|
||||
logger.exception("数据质量检查失败")
|
||||
await asyncio.sleep(3600) # 每小时
|
||||
|
||||
async def _run_checks(self) -> None:
|
||||
"""执行数据质量检查并写入 DataQualityCheck 表。"""
|
||||
from src.db.base import AsyncSessionLocal
|
||||
from src.db.models import DataQualityCheck, Match, MatchStats, Standing, League
|
||||
from sqlalchemy import func, select
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
# 检查1: 已完赛但无统计的比赛数
|
||||
finished_no_stats = (
|
||||
await db.execute(
|
||||
select(func.count())
|
||||
.select_from(Match)
|
||||
.outerjoin(MatchStats, Match.id == MatchStats.match_id)
|
||||
.where(Match.match_status == "finished")
|
||||
.where(MatchStats.id.is_(None))
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
db.add(DataQualityCheck(
|
||||
check_name="finished_without_stats",
|
||||
entity_type="match",
|
||||
actual_value=float(finished_no_stats),
|
||||
passed=finished_no_stats == 0,
|
||||
severity="warning" if finished_no_stats > 0 else "info",
|
||||
detail={"message": f"{finished_no_stats} 场已完赛比赛缺少统计数据"},
|
||||
))
|
||||
|
||||
# 检查2: 积分榜缺失的联赛数
|
||||
leagues_without_standings = (
|
||||
await db.execute(
|
||||
select(func.count())
|
||||
.select_from(League)
|
||||
.outerjoin(Standing, League.id == Standing.league_id)
|
||||
.where(Standing.id.is_(None))
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
db.add(DataQualityCheck(
|
||||
check_name="league_without_standings",
|
||||
entity_type="league",
|
||||
actual_value=float(leagues_without_standings),
|
||||
passed=leagues_without_standings == 0,
|
||||
severity="warning" if leagues_without_standings > 0 else "info",
|
||||
detail={"message": f"{leagues_without_standings} 个联赛缺少积分榜"},
|
||||
))
|
||||
|
||||
await db.commit()
|
||||
logger.info("数据质量检查完成: stats=%d, standings=%d", finished_no_stats, leagues_without_standings)
|
||||
|
||||
|
||||
# 全局单例
|
||||
quality_scheduler = DataQualityScheduler()
|
||||
|
||||
|
||||
class Scheduler:
|
||||
"""全局定时任务调度器。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._tasks: dict[str, ScheduledTask] = {}
|
||||
self._running = False
|
||||
|
||||
def register(
|
||||
self,
|
||||
task_id: str,
|
||||
cron: str,
|
||||
fn: Callable[[], Coroutine],
|
||||
enabled: bool = True,
|
||||
) -> ScheduledTask:
|
||||
"""注册(或更新)一个定时任务。
|
||||
|
||||
若调度器已 start,新任务会立即启动其运行循环 ——
|
||||
否则运行期通过 API 新建的任务永远不会被执行(全量审查 C1 缺陷 2)。
|
||||
"""
|
||||
existing = self._tasks.get(task_id)
|
||||
if existing is not None:
|
||||
existing.update(cron=cron, enabled=enabled)
|
||||
# 更新 cron 后重新校验:改坏了要立刻报错,而不是静默失活
|
||||
existing._calc_next(raise_on_error=True)
|
||||
existing.fn = fn
|
||||
task = existing
|
||||
else:
|
||||
task = ScheduledTask(task_id, cron, fn, enabled)
|
||||
self._tasks[task_id] = task
|
||||
|
||||
if self._running:
|
||||
self._ensure_loop(task)
|
||||
return task
|
||||
|
||||
def _ensure_loop(self, task: ScheduledTask) -> None:
|
||||
"""为任务启动运行循环(幂等:已在运行则跳过)。"""
|
||||
if task._task is None or task._task.done():
|
||||
task._task = asyncio.create_task(task._run_loop())
|
||||
|
||||
def get(self, task_id: str) -> ScheduledTask | None:
|
||||
return self._tasks.get(task_id)
|
||||
|
||||
def list_all(self) -> list[ScheduledTask]:
|
||||
return list(self._tasks.values())
|
||||
|
||||
def remove(self, task_id: str) -> None:
|
||||
task = self._tasks.pop(task_id, None)
|
||||
if task is not None and task._task is not None:
|
||||
task._task.cancel()
|
||||
|
||||
async def start(self) -> None:
|
||||
self._running = True
|
||||
for task in self._tasks.values():
|
||||
self._ensure_loop(task)
|
||||
logger.info("定时调度器已启动, 共 %d 个任务", len(self._tasks))
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._running = False
|
||||
for task in self._tasks.values():
|
||||
if task._task:
|
||||
task._task.cancel()
|
||||
self._tasks.clear()
|
||||
|
||||
|
||||
# 全局单例
|
||||
scheduler = Scheduler()
|
||||
@@ -0,0 +1,121 @@
|
||||
"""生产环境启动安全校验:缺失关键配置则拒绝启动(生产)或警告(开发)。
|
||||
|
||||
校验项:
|
||||
- SECRET_KEY 非空且非弱默认值
|
||||
- 鉴权已配置(密码哈希 / .env 明文密码 / API Key 任一)
|
||||
- DATABASE_URL 不使用示例弱密码(football:football)
|
||||
|
||||
与 deps.py 的 fail-closed 互补:此处是「启动时一次性校验 + 明确报错」,
|
||||
避免生产带着危险配置上线却只在被攻击时才暴露。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from src.core.config import settings
|
||||
from src.core.runtime_config import get_admin_password_hash
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 明显的弱 SECRET_KEY 黑名单(大小写无关)
|
||||
_WEAK_SECRET_KEYS = {
|
||||
"", "changeme", "secret", "password", "123456", "admin",
|
||||
"default", "dev", "development", "test", "example",
|
||||
"openssl rand -base64 32", # 有人把生成指令直接粘进去
|
||||
}
|
||||
_MIN_SECRET_KEY_LEN = 16
|
||||
|
||||
# 示例弱数据库密码(仅识别最明显的;自定义强密码不受影响)
|
||||
_WEAK_DB_PATTERNS = ("football:football@", "admin:admin@", "password@", "123456@")
|
||||
|
||||
|
||||
class SecurityCheckError(Exception):
|
||||
"""生产环境安全校验失败。"""
|
||||
|
||||
|
||||
async def _auth_configured() -> bool:
|
||||
"""运行时鉴权是否已配置(含数据库密码哈希/.env 明文/API Key)。"""
|
||||
if await get_admin_password_hash():
|
||||
return True
|
||||
if settings.ADMIN_PASSWORD or settings.ADMIN_API_KEY:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _check_secret_key() -> list[str]:
|
||||
"""返回 SECRET_KEY 的问题列表(空=通过)。"""
|
||||
problems: list[str] = []
|
||||
key = settings.SECRET_KEY
|
||||
if not key:
|
||||
problems.append("SECRET_KEY 未设置,加密与会话签名无法保障")
|
||||
return problems
|
||||
if key.lower().strip() in _WEAK_SECRET_KEYS:
|
||||
problems.append(f"SECRET_KEY 为弱默认值({key[:20]}...),请生成强随机值: openssl rand -base64 32")
|
||||
elif len(key) < _MIN_SECRET_KEY_LEN:
|
||||
problems.append(f"SECRET_KEY 过短({len(key)} 字符),建议至少 {_MIN_SECRET_KEY_LEN} 位")
|
||||
return problems
|
||||
|
||||
|
||||
def _check_database_url() -> list[str]:
|
||||
problems: list[str] = []
|
||||
url = settings.DATABASE_URL.lower()
|
||||
for pat in _WEAK_DB_PATTERNS:
|
||||
if pat in url:
|
||||
problems.append(f"DATABASE_URL 使用示例弱密码({pat.rstrip('@')}),生产环境必须更换")
|
||||
break
|
||||
return problems
|
||||
|
||||
|
||||
async def validate_security() -> dict:
|
||||
"""执行安全校验。
|
||||
|
||||
返回 {"ok": bool, "errors": [...], "warnings": [...]}。
|
||||
errors 为阻断性问题,warnings 为建议。
|
||||
"""
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
errors.extend(_check_secret_key())
|
||||
if not await _auth_configured():
|
||||
errors.append("管理鉴权未配置:请设置 ADMIN_PASSWORD 或 ADMIN_API_KEY")
|
||||
warnings.extend(_check_database_url())
|
||||
|
||||
# 生产环境:DB 弱密码也升级为阻断
|
||||
if settings.APP_ENV == "production" and warnings:
|
||||
errors.extend(warnings)
|
||||
warnings = []
|
||||
|
||||
ok = not errors
|
||||
return {"ok": ok, "errors": errors, "warnings": warnings}
|
||||
|
||||
|
||||
async def assert_security_on_startup() -> None:
|
||||
"""启动入口:生产环境校验失败则拒绝启动,开发环境仅警告。"""
|
||||
result = await validate_security()
|
||||
|
||||
for w in result["warnings"]:
|
||||
logger.warning("[security-check] %s", w)
|
||||
|
||||
if result["ok"]:
|
||||
if result["warnings"]:
|
||||
logger.warning("[security-check] 存在 %d 项警告,建议修复", len(result["warnings"]))
|
||||
else:
|
||||
logger.info("[security-check] 安全校验通过")
|
||||
return
|
||||
|
||||
# 阻断
|
||||
is_prod = settings.APP_ENV == "production"
|
||||
level = logging.ERROR if is_prod else logging.WARNING
|
||||
for e in result["errors"]:
|
||||
logger.log(level, "[security-check] %s", e)
|
||||
|
||||
if is_prod:
|
||||
logger.critical(
|
||||
"[security-check] 生产环境安全校验失败,拒绝启动。请修复上述 %d 项问题后重试。",
|
||||
len(result["errors"]),
|
||||
)
|
||||
# 明确退出,避免带着危险配置上线
|
||||
sys.exit(1)
|
||||
|
||||
logger.warning("[security-check] 开发环境存在 %d 项问题(未阻断),请尽快修复", len(result["errors"]))
|
||||
+590
-112
@@ -1,28 +1,35 @@
|
||||
"""Bzzoiro 数据源:抓取 + 入库。
|
||||
"""Bzzoiro 数据源:抓取 + 入库(单一数据源)。
|
||||
|
||||
迁移自旧项目 app/data/sources/bzzoiro/,改成 async + 简化入库。
|
||||
使用 Repository 模式进行数据访问,不直接控制事务。
|
||||
三条管线:
|
||||
1. events — 比赛日程/比分(/events/),含 source_event_id 血缘
|
||||
2. standings— 联赛积分榜快照(/leagues/{id}/standings/)
|
||||
3. stats — 已完赛比赛详细统计回填(/events/{id}/stats/)
|
||||
|
||||
D4(工程债): Team/League/Match 的查找/创建经 Repository 层(src/db/repositories.py),
|
||||
本模块不直接控制事务(commit/rollback 由调用方 UnitOfWork 控制,这里只 flush)。
|
||||
Standing/RawEvent/Lineage 等管线内私有读写仍在本模块内实现,不强行 Repository 化。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json as _json
|
||||
import logging
|
||||
import random
|
||||
import time as _time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from collections.abc import Iterable
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.core.config import settings
|
||||
import httpx
|
||||
|
||||
from src.core.runtime_config import get_runtime_value
|
||||
from src.core.http_client import get_client
|
||||
from src.data.config import BZZOIRO_LEAGUE_IDS, LEAGUE_COUNTRIES, LEAGUE_NAMES, REQUEST_INTERVAL
|
||||
from src.data.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, Team
|
||||
from src.db.models import Match, MatchStats, Standing, Team, RawEvent, IngestFailure, DataLineage
|
||||
from src.db.repositories import LeagueRepository, MatchRepository, TeamRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -36,6 +43,16 @@ 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 字符串)。
|
||||
|
||||
@@ -47,43 +64,71 @@ def _match_key(home_team_id: int, away_team_id: int, match_date) -> tuple[int, i
|
||||
return (home_team_id, away_team_id, d.isoformat() if d is not None else "")
|
||||
|
||||
|
||||
def _fetch_json_sync(path: str, params: dict | None = None, max_retries: int = 3) -> dict | list:
|
||||
"""同步 HTTP(bzzoiro 客户端保持同步,在 async 函数里 run_in_executor)。"""
|
||||
base = settings.BZZOIRO_BASE.rstrip("/")
|
||||
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)
|
||||
|
||||
url = f"{base}/{path.lstrip('/')}"
|
||||
if params:
|
||||
url += "?" + urllib.parse.urlencode(params)
|
||||
key = settings.BZZOIRO_KEY
|
||||
key = ring.get()
|
||||
if not key:
|
||||
raise RuntimeError("BZZOIRO_KEY 未设置")
|
||||
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(max_retries):
|
||||
headers = {
|
||||
"Authorization": f"Token {key}",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
try:
|
||||
req = urllib.request.Request(url)
|
||||
req.add_header("Authorization", f"Token {key}")
|
||||
req.add_header("Accept", "application/json")
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return _json.loads(resp.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as e:
|
||||
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.raise_for_status()
|
||||
return resp.json()
|
||||
except Exception as e:
|
||||
last_exc = e
|
||||
if e.code == 429:
|
||||
# 指数退避: 429 通常意味着限速
|
||||
delay = min(2 ** attempt, 16) + random.uniform(0, 1)
|
||||
logger.warning("bzzoiro 429, retry %d in %.1fs", attempt + 1, delay)
|
||||
_time.sleep(delay)
|
||||
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
|
||||
continue
|
||||
if 500 <= e.code < 600:
|
||||
if 500 <= (status or 0) < 600:
|
||||
delay = min(2 ** attempt, 16) + random.uniform(0, 1)
|
||||
logger.warning("bzzoiro %d, retry %d in %.1fs", e.code, attempt + 1, delay)
|
||||
_time.sleep(delay)
|
||||
logger.warning("bzzoiro %d, retry %d in %.1fs", status, attempt + 1, delay)
|
||||
await asyncio.sleep(delay)
|
||||
continue
|
||||
raise # 4xx 直接抛
|
||||
except (urllib.error.URLError, TimeoutError, ConnectionError) as e:
|
||||
last_exc = e
|
||||
delay = min(2 ** attempt, 16) + random.uniform(0, 1)
|
||||
logger.warning("bzzoiro network error, retry %d in %.1fs: %s", attempt + 1, delay, e)
|
||||
_time.sleep(delay)
|
||||
# 网络错误(连接失败/超时)也退避重试
|
||||
if isinstance(e, (TimeoutError, ConnectionError, OSError)):
|
||||
delay = min(2 ** attempt, 16) + random.uniform(0, 1)
|
||||
logger.warning("bzzoiro network error, retry %d in %.1fs: %s", attempt + 1, delay, e)
|
||||
await asyncio.sleep(delay)
|
||||
continue
|
||||
raise
|
||||
raise RuntimeError(f"bzzoiro request failed after {max_retries} attempts: {last_exc}")
|
||||
|
||||
|
||||
@@ -95,15 +140,13 @@ async def fetch_bzzoiro_events(
|
||||
date_to: str | None = None,
|
||||
limit: int = 200,
|
||||
) -> list[dict]:
|
||||
"""抓取 bzzoiro 原始事件(异步包装)。"""
|
||||
"""抓取 bzzoiro 原始事件(纯异步,无需 run_in_executor)。"""
|
||||
league_id = BZZOIRO_LEAGUE_IDS.get(league_code)
|
||||
if league_id is None:
|
||||
raise ValueError(f"未知联赛代码: {league_code}")
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
rows: list[dict] = []
|
||||
offset = 0
|
||||
payload: dict | list = {}
|
||||
while True:
|
||||
params: dict = {
|
||||
"league_id": league_id,
|
||||
@@ -115,8 +158,7 @@ async def fetch_bzzoiro_events(
|
||||
params["date_from"] = str(date_from)[:10]
|
||||
if date_to:
|
||||
params["date_to"] = str(date_to)[:10]
|
||||
# 显式位置参数,避免 lambda 闭包捕获循环变量
|
||||
payload = await loop.run_in_executor(None, _fetch_json_sync, "/events/", params)
|
||||
payload = await _fetch_json_async("/events/", params)
|
||||
batch = payload.get("results") or []
|
||||
if not batch:
|
||||
break
|
||||
@@ -157,23 +199,30 @@ 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
|
||||
|
||||
# 获取或创建联赛
|
||||
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()
|
||||
# D4: 联赛查找/创建经 LeagueRepository(事务仍由调用方 UoW 提交)
|
||||
league = await LeagueRepository(db).get_or_create(
|
||||
code, LEAGUE_NAMES.get(code, code), LEAGUE_COUNTRIES.get(code)
|
||||
)
|
||||
team_r = TeamRepository(db)
|
||||
match_r = MatchRepository(db)
|
||||
|
||||
# === 批量优化: 预加载球队和已有比赛到内存 ===
|
||||
team_name_to_id: dict[str, int] = {}
|
||||
existing_matches: dict[tuple[int, int, str], Match] = {} # 完整对象,避免重复查询
|
||||
# (NormalizedMatch, 原始 event) 成对保存:后续写 source_record_id 时
|
||||
# (NormalizedMatch, 原始 event) 成对保存:后续写 source_event_id 时
|
||||
# 必须用配对的那条 event,不能依赖外层循环变量残留值。
|
||||
normalized_matches: list[tuple] = []
|
||||
|
||||
@@ -186,39 +235,55 @@ class BzzoiroSource:
|
||||
try:
|
||||
nm.validate()
|
||||
except Exception as e:
|
||||
logger.debug("normalize skip: %s", e)
|
||||
league_r["errors"].append(f"normalize: {e}")
|
||||
# P1-3: 统一使用 warning,不追加到 errors(仅运行时错误入 errors)
|
||||
logger.warning("normalize skip: %s", e)
|
||||
continue
|
||||
normalized_matches.append((nm, raw))
|
||||
all_team_names.add(nm.home_team)
|
||||
all_team_names.add(nm.away_team)
|
||||
|
||||
if all_team_names:
|
||||
stmt = select(Team).where(Team.name.in_(all_team_names))
|
||||
teams = (await db.execute(stmt)).scalars().all()
|
||||
team_name_to_id = {t.name: t.id for t in teams}
|
||||
team_name_to_id = {
|
||||
name: t.id
|
||||
for name, t in (await team_r.get_all_by_names(list(all_team_names))).items()
|
||||
}
|
||||
|
||||
# 预加载已有比赛(完整对象)
|
||||
stmt = select(Match).where(Match.league_id == league.id)
|
||||
for m in (await db.execute(stmt)).scalars():
|
||||
key = _match_key(m.home_team_id, m.away_team_id, m.match_date_date)
|
||||
existing_matches[key] = m
|
||||
# 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]
|
||||
if dates:
|
||||
min_dt = min(dates) - timedelta(days=30)
|
||||
max_dt = max(dates) + timedelta(days=30)
|
||||
matches_in_range = await match_r.find_by_league_and_date_range(
|
||||
league.id, min_dt, max_dt
|
||||
)
|
||||
existing_matches = {
|
||||
_match_key(m.home_team_id, m.away_team_id, m.match_date_date): m
|
||||
for m in matches_in_range
|
||||
}
|
||||
# else: existing_matches 保持空 dict(全量新比赛)
|
||||
|
||||
# D1: Bronze 层批次信息(每联赛每批次一个 batch_id;seen 防同批重复写入)
|
||||
now = datetime.now(timezone.utc)
|
||||
bronze_batch_id = f"bzzoiro-events-{code}-{now:%Y%m%d%H%M%S}"
|
||||
bronze_written: set[str] = set()
|
||||
|
||||
for nm, raw in normalized_matches:
|
||||
# 球队: 内存查找 + 按需创建
|
||||
# D1: RawEvent 幂等键(上游 id 或合成键),插入/变更更新共用
|
||||
record_id = _events_record_id(code, nm, raw)
|
||||
|
||||
# 球队: 内存查找 + 按需创建(D4: 经 TeamRepository)
|
||||
home_team_id = team_name_to_id.get(nm.home_team)
|
||||
if home_team_id is None:
|
||||
home = Team(name=nm.home_team)
|
||||
db.add(home)
|
||||
await db.flush()
|
||||
home = await team_r.get_or_create(nm.home_team, name_zh=zh_name(nm.home_team))
|
||||
home_team_id = home.id
|
||||
team_name_to_id[nm.home_team] = home_team_id
|
||||
|
||||
away_team_id = team_name_to_id.get(nm.away_team)
|
||||
if away_team_id is None:
|
||||
away = Team(name=nm.away_team)
|
||||
db.add(away)
|
||||
await db.flush()
|
||||
away = await team_r.get_or_create(nm.away_team, name_zh=zh_name(nm.away_team))
|
||||
away_team_id = away.id
|
||||
team_name_to_id[nm.away_team] = away_team_id
|
||||
|
||||
@@ -240,34 +305,26 @@ 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 # 防止同批重复
|
||||
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)
|
||||
# 统计字段不在 /events/ 载荷中(单独由 stats 管线回填),
|
||||
# 此处不再创建 MatchStats。
|
||||
league_r["inserted"] += 1
|
||||
# D1: 成功插入 → 补写 Bronze 层(原始载荷 + 血缘)
|
||||
if record_id not in bronze_written:
|
||||
bronze_written.add(record_id)
|
||||
await _write_events_bronze(
|
||||
db,
|
||||
source_record_id=record_id,
|
||||
raw_payload=raw,
|
||||
target_match_id=m.id,
|
||||
league_code=code,
|
||||
match_status=nm.match_status,
|
||||
batch_id=bronze_batch_id,
|
||||
)
|
||||
else:
|
||||
# 已有比赛: 直接从内存获取对象更新(无需再查询)
|
||||
changed = False
|
||||
@@ -283,33 +340,454 @@ 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.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 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 changed:
|
||||
league_r["updated"] += 1
|
||||
# D1: 变更更新 → 补写血缘(RawEvent 幂等键不变,重复采集自动跳过)
|
||||
if record_id not in bronze_written:
|
||||
bronze_written.add(record_id)
|
||||
await _write_events_bronze(
|
||||
db,
|
||||
source_record_id=record_id,
|
||||
raw_payload=raw,
|
||||
target_match_id=existing_match.id,
|
||||
league_code=code,
|
||||
match_status=nm.match_status,
|
||||
batch_id=bronze_batch_id,
|
||||
)
|
||||
|
||||
# 注意: 不在此处 commit,由调用方 UnitOfWork 控制事务
|
||||
result["leagues"][code] = league_r
|
||||
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,
|
||||
))
|
||||
|
||||
|
||||
def _events_record_id(league_code: str, nm, raw: dict) -> str:
|
||||
"""events 载荷的 RawEvent 幂等键。
|
||||
|
||||
优先用上游 event id;缺失时用 (league:home:away:date) 合成稳定键 ——
|
||||
取 normalize 后的队名与天级日期(与 _match_key 同口径),不依赖 DB 自增 id,
|
||||
保证同一来源比赛重复采集时命中同一条 RawEvent,不产生重复原始载荷。
|
||||
"""
|
||||
eid = _to_int_or_none(raw.get("id"))
|
||||
if eid is not None:
|
||||
return str(eid)
|
||||
d = _to_date(nm.date)
|
||||
date_part = d.isoformat() if d is not None else "na"
|
||||
return f"{league_code}:{nm.home_team}:{nm.away_team}:{date_part}"
|
||||
|
||||
|
||||
async def _write_events_bronze(
|
||||
db,
|
||||
*,
|
||||
source_record_id: str,
|
||||
raw_payload: dict,
|
||||
target_match_id: int | None,
|
||||
league_code: str,
|
||||
match_status: str | None,
|
||||
batch_id: str,
|
||||
) -> None:
|
||||
"""events 成功插入/更新单场比赛后的 Bronze 层补写:RawEvent(幂等) + DataLineage。
|
||||
|
||||
D1(工程债):此前只有 stats 回填写 RawEvent/Lineage,events 管线作为比赛
|
||||
主数据的唯一入口反而不留溯源记录。幂等性由 _write_raw_event 的
|
||||
source_record_id 查重保证;best-effort:基础设施写入失败只记 warning,
|
||||
绝不拖垮采集主流程(与 _safe_write_ingest_failure 同级约束)。
|
||||
"""
|
||||
try:
|
||||
await _write_raw_event(db, "bzzoiro", source_record_id, raw_payload, batch_id)
|
||||
await _write_lineage(
|
||||
db, "bzzoiro", source_record_id,
|
||||
"matches", target_match_id, "events_ingest",
|
||||
{"league": league_code, "match_status": match_status},
|
||||
batch_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"events Bronze 写入失败(record=%s, match=%s),不影响采集主流程",
|
||||
source_record_id, target_match_id, exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 积分榜管线:/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,D4: 经 LeagueRepository)
|
||||
league = await LeagueRepository(db).get_or_create(
|
||||
code, LEAGUE_NAMES.get(code, code), LEAGUE_COUNTRIES.get(code)
|
||||
)
|
||||
team_r = TeamRepository(db)
|
||||
|
||||
# 赛季标签:优先用返回的 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] = await team_r.get_all_by_names(list(names))
|
||||
|
||||
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 = await team_r.get_or_create(team_name, name_zh=zh_name(team_name))
|
||||
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
|
||||
|
||||
# D4: 候选比赛查询经 MatchRepository(含 stats 预加载,筛选/排序/limit 语义不变)
|
||||
matches = await MatchRepository(db).find_finished_with_stats(
|
||||
league_ids, limit=limit * 3 if only_missing else limit
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user