Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83a1eed283 | ||
|
|
9c77efaa4b | ||
|
|
b9beddacf7 | ||
|
|
432bfee8ad | ||
|
|
f1c586111f | ||
|
|
db3c64d49b | ||
|
|
d013407aa1 | ||
|
|
0ab838258c | ||
|
|
9cedb874f7 | ||
|
|
1cdc6411f5 | ||
|
|
f0c1cc1491 | ||
|
|
8fef411ced | ||
|
|
197641b9f7 | ||
|
|
1a9dc63edd | ||
|
|
b15678b3f6 | ||
|
|
28ecf85da8 | ||
|
|
b749b1c621 | ||
|
|
69ec19e646 | ||
|
|
5f4075be22 | ||
|
|
eae88f4cd9 | ||
|
|
2b52478b8f | ||
|
|
b30ad56319 | ||
|
|
b997c06ede | ||
|
|
1ddf697c97 | ||
|
|
5ff43d4984 | ||
|
|
41cb2edd47 | ||
|
|
64ae8e663a | ||
|
|
49d78136a1 | ||
|
|
63caa6736c | ||
|
|
f563d5cc99 | ||
|
|
7d2eabf750 |
@@ -3,6 +3,9 @@
|
|||||||
# production 启动时会强制校验:SECRET_KEY 非空且非弱值、鉴权已配置、DB 弱密码阻断。
|
# production 启动时会强制校验:SECRET_KEY 非空且非弱值、鉴权已配置、DB 弱密码阻断。
|
||||||
APP_ENV=development
|
APP_ENV=development
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
# 日志持久化:空=仅 stdout + Admin 内存日志页(重启清零);填路径则额外写滚动文件(10MB×5)。
|
||||||
|
# 本地开发示例: LOG_FILE=./logs/app.log (容器内由 compose 默认设为 /app/logs/app.log 并挂卷)
|
||||||
|
LOG_FILE=
|
||||||
API_PORT=8000
|
API_PORT=8000
|
||||||
FRONTEND_PORT=3000
|
FRONTEND_PORT=3000
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ __pycache__/
|
|||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
|
logs/
|
||||||
|
|
||||||
# AI 助手上下文文件(不入库)
|
# AI 助手上下文文件(不入库)
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ Profeto/
|
|||||||
│ │ ├── config.py # pydantic-settings 配置
|
│ │ ├── config.py # pydantic-settings 配置
|
||||||
│ │ ├── crypto.py # 加密/哈希
|
│ │ ├── crypto.py # 加密/哈希
|
||||||
│ │ ├── http_client.py # 共享 httpx 客户端
|
│ │ ├── http_client.py # 共享 httpx 客户端
|
||||||
│ │ ├── log_buffer.py # 内存日志缓冲(admin 日志页)
|
│ │ ├── log_buffer.py # 日志基础设施:内存环形缓冲(admin 日志页) + 可选文件持久化(LOG_FILE)
|
||||||
│ │ ├── runtime_config.py # DB 配置覆盖(.env → app_settings)
|
│ │ ├── runtime_config.py # DB 配置覆盖(.env → app_settings)
|
||||||
│ │ ├── scheduler.py # 进程内 cron 调度器
|
│ │ ├── scheduler.py # 进程内 cron 调度器
|
||||||
│ │ └── security_check.py # 启动安全校验
|
│ │ └── security_check.py # 启动安全校验
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ def upgrade() -> None:
|
|||||||
'matches',
|
'matches',
|
||||||
['source_event_id'],
|
['source_event_id'],
|
||||||
unique=True,
|
unique=True,
|
||||||
postgresql_where=op.text('source_event_id IS NOT NULL'),
|
postgresql_where='source_event_id IS NOT NULL',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""P0-01: 比分可信度——score_status + 允许完赛缺分(NULL,禁止伪造 0:0)
|
||||||
|
|
||||||
|
替换 ck_matches_finished_has_score:引入 score_status(known/missing/unknown),
|
||||||
|
完赛 + score_status=missing 时 home/away_goals 必须 NULL(不伪造比分)。
|
||||||
|
|
||||||
|
Revision ID: 0022_match_score_status
|
||||||
|
Revises: 0021_match_source_event_id_unique
|
||||||
|
Create Date: 2026-09-22
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = '0022_match_score_status'
|
||||||
|
down_revision: Union[str, None] = '0021_match_source_event_id_unique'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# 1) 新增 score_status 列(默认 unknown)
|
||||||
|
op.add_column(
|
||||||
|
'matches',
|
||||||
|
sa.Column('score_status', sa.String(20), server_default='unknown', nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2) 按现有数据回填 score_status(绝不写 goals=0):
|
||||||
|
# - 有比分(两列均非 NULL) → known
|
||||||
|
# - 无比分 + 完赛 → missing(缺分)
|
||||||
|
# - 其余 → unknown
|
||||||
|
op.execute(
|
||||||
|
"UPDATE matches SET score_status = 'known'"
|
||||||
|
" WHERE home_goals IS NOT NULL AND away_goals IS NOT NULL"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"UPDATE matches SET score_status = 'missing'"
|
||||||
|
" WHERE match_status = 'finished' AND home_goals IS NULL AND away_goals IS NULL"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3) 删除旧约束,加新约束
|
||||||
|
op.drop_constraint('ck_matches_finished_has_score', 'matches', type_='check')
|
||||||
|
op.create_check_constraint(
|
||||||
|
'ck_matches_score_status_enum', 'matches',
|
||||||
|
"score_status IN ('known', 'missing', 'unknown')",
|
||||||
|
)
|
||||||
|
op.create_check_constraint(
|
||||||
|
'ck_matches_score_integrity', 'matches',
|
||||||
|
"match_status <> 'finished'"
|
||||||
|
" OR (score_status = 'known' AND home_goals IS NOT NULL AND away_goals IS NOT NULL)"
|
||||||
|
" OR (score_status IN ('missing', 'unknown') AND home_goals IS NULL AND away_goals IS NULL)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_constraint('ck_matches_score_integrity', 'matches', type_='check')
|
||||||
|
op.drop_constraint('ck_matches_score_status_enum', 'matches', type_='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.remove_column('matches', 'score_status')
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""P0-02: 积分榜改为追加快照(append-only) + available_at
|
||||||
|
|
||||||
|
去掉 uq_standings_league_season_team(league,season,team 唯一),
|
||||||
|
改为 (league, season, team, available_at) 唯一;
|
||||||
|
每次采集 INSERT 新行(available_at=now),支持回测还原历史榜单。
|
||||||
|
|
||||||
|
Revision ID: 0023_standings_append_only
|
||||||
|
Revises: 0022_match_score_status
|
||||||
|
Create Date: 2026-09-22
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = '0023_standings_append_only'
|
||||||
|
down_revision: Union[str, None] = '0022_match_score_status'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
# 1) 加 available_at 列(非空,默认 now;存量回填 retrieved_at 或 now)
|
||||||
|
op.add_column(
|
||||||
|
'standings',
|
||||||
|
sa.Column('available_at', sa.DateTime(timezone=True), nullable=False,
|
||||||
|
server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
# 存量行: available_at 取 retrieved_at(若存在)否则 now
|
||||||
|
op.execute("UPDATE standings SET available_at = COALESCE(retrieved_at, NOW())")
|
||||||
|
|
||||||
|
# 2) 去旧唯一约束,加新唯一约束(league, season, team, available_at)
|
||||||
|
op.drop_constraint('uq_standings_league_season_team', 'standings', type_='unique')
|
||||||
|
# 原始索引名拼写为 leason(历史遗留),按实际库名删除
|
||||||
|
op.drop_index('ix_standings_league_season_pos', table_name='standings', if_exists=True)
|
||||||
|
op.create_index('ix_standings_league_season_pos_v2', 'standings', ['league_id', 'season', 'position'])
|
||||||
|
op.create_unique_constraint(
|
||||||
|
'uq_standings_league_season_team_available', 'standings',
|
||||||
|
['league_id', 'season', 'team_id', 'available_at'],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
'ix_standings_league_season_team_available', 'standings',
|
||||||
|
['league_id', 'season', 'team_id', 'available_at'],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index('ix_standings_league_season_team_available', table_name='standings')
|
||||||
|
op.drop_constraint('uq_standings_league_season_team_available', 'standings', type_='unique')
|
||||||
|
op.drop_index('ix_standings_league_season_pos', table_name='standings')
|
||||||
|
op.create_index('ix_standings_leason_season_pos', 'standings', ['league_id', 'season', 'position'])
|
||||||
|
op.create_unique_constraint(
|
||||||
|
'uq_standings_league_season_team', 'standings',
|
||||||
|
['league_id', 'season', 'team_id'],
|
||||||
|
)
|
||||||
|
op.drop_column('standings', 'available_at')
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""P0-03: Prediction 幂等指纹——移除旧唯一约束,改为 partial unique on input_hash
|
||||||
|
|
||||||
|
input_hash 非空时唯一(同指纹返回已有行,不 UPDATE/INSERT);
|
||||||
|
兼容旧数据 NULL input_hash(不强制回填)。
|
||||||
|
|
||||||
|
Revision ID: 0024_prediction_idempotent_fingerprint
|
||||||
|
Revises: 0023_standings_append_only
|
||||||
|
Create Date: 2026-09-22
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = '0024_prediction_idempotent_fingerprint'
|
||||||
|
down_revision: Union[str, None] = '0023_standings_append_only'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# 移除旧唯一约束(match, provider, model, mode, run_type)
|
||||||
|
op.drop_constraint(
|
||||||
|
'uq_predictions_match_provider_model_mode_run_type',
|
||||||
|
'predictions', type_='unique',
|
||||||
|
)
|
||||||
|
# P0-03: partial unique on input_hash(非空时唯一)
|
||||||
|
op.create_index(
|
||||||
|
'ix_predictions_input_hash_unique', 'predictions', ['input_hash'], unique=True,
|
||||||
|
postgresql_where=sa.text('input_hash IS NOT NULL'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index('ix_predictions_input_hash_unique', table_name='predictions')
|
||||||
|
op.create_unique_constraint(
|
||||||
|
'uq_predictions_match_provider_model_mode_run_type',
|
||||||
|
'predictions',
|
||||||
|
['match_id', 'provider', 'model', 'mode', 'run_type'],
|
||||||
|
)
|
||||||
+6
-2
@@ -28,13 +28,15 @@ services:
|
|||||||
# Fix 2: 容器内 DATABASE_URL 使用 postgres 服务名(非 localhost)
|
# Fix 2: 容器内 DATABASE_URL 使用 postgres 服务名(非 localhost)
|
||||||
# 必须覆盖 .env 中的 DATABASE_URL,因为 Settings 不读 DB_HOST/DB_PORT
|
# 必须覆盖 .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}
|
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:?POSTGRES_USER 未设置}:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD 未设置}@postgres:5432/${POSTGRES_DB:-football}
|
||||||
|
# 日志持久化:写入挂载卷,容器重建不丢(应用内滚动 10MB×6 份封顶)
|
||||||
|
LOG_FILE: /app/logs/app.log
|
||||||
env_file: .env
|
env_file: .env
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "curl -sf http://localhost:8000/health/ready || exit 1"]
|
test: ["CMD-SHELL", "python -c \"import urllib.request; exit(0 if urllib.request.urlopen('http://localhost:8000/health/ready', timeout=5).status==200 else 1)\" || exit 1"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 10s
|
start_period: 15s
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -42,6 +44,7 @@ services:
|
|||||||
- ./src:/app/src
|
- ./src:/app/src
|
||||||
- ./alembic:/app/alembic
|
- ./alembic:/app/alembic
|
||||||
- ./alembic.ini:/app/alembic.ini
|
- ./alembic.ini:/app/alembic.ini
|
||||||
|
- applogs:/app/logs
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
# Fix 4: 多阶段构建 —— 先 build 静态文件,再复制到 nginx
|
# Fix 4: 多阶段构建 —— 先 build 静态文件,再复制到 nginx
|
||||||
@@ -57,3 +60,4 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
|
applogs:
|
||||||
|
|||||||
+2
-2
@@ -206,7 +206,7 @@ CREATE TABLE predictions (
|
|||||||
|
|
||||||
| 表 | 状态 | 用途 |
|
| 表 | 状态 | 用途 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `standings` | 已启用 | 联赛积分榜快照,按 `(league_id, season, team_id)` upsert,同联赛同赛季只保留最新快照;含排名/战绩/进失球/积分/分区(zone) |
|
| `standings` | 已启用 | 联赛积分榜追加快照(P0-02):每次采集 INSERT 新行(available_at=now),唯一键 `(league_id, season, team_id, available_at)`;查询取每队 available_at 最新快照,支持回测还原历史榜单。含排名/战绩/进失球/积分/分区(zone) |
|
||||||
| `app_settings` | 已启用 | 后台运行时设置(如数据源 API Key),读取时优先于 `.env` 默认值 |
|
| `app_settings` | 已启用 | 后台运行时设置(如数据源 API Key),读取时优先于 `.env` 默认值 |
|
||||||
| `schedules` | 已启用 | 定时采集任务配置(task/cron/leagues/enabled),供内置调度器执行 |
|
| `schedules` | 已启用 | 定时采集任务配置(task/cron/leagues/enabled),供内置调度器执行 |
|
||||||
| `raw_events` | 预留未启用 | Bronze 层原始事件存档;规划中用于重放与审计 |
|
| `raw_events` | 预留未启用 | Bronze 层原始事件存档;规划中用于重放与审计 |
|
||||||
@@ -250,7 +250,7 @@ events 管线按以下优先级定位已有比赛,命中即复用(更新):
|
|||||||
|
|
||||||
`task=stats` 只回填统计(xG/射门/控球等,也只补空),不创建比赛。
|
`task=stats` 只回填统计(xG/射门/控球等,也只补空),不创建比赛。
|
||||||
|
|
||||||
`task=standings` 按 `(league_id, season, team_id)` upsert 积分榜快照,同一联赛同一赛季只保留最新一份。
|
`task=standings` 追加快照(available_at=now,ON CONFLICT DO NOTHING);公开接口与切片均取每队 available_at 最新快照,支持回测还原历史榜单。
|
||||||
|
|
||||||
## 采集建议
|
## 采集建议
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ cd frontend && npm install && npm run dev
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `APP_ENV` | ❌ | `development` | `production` / `development` |
|
| `APP_ENV` | ❌ | `development` | `production` / `development` |
|
||||||
| `LOG_LEVEL` | ❌ | `INFO` | 日志级别 |
|
| `LOG_LEVEL` | ❌ | `INFO` | 日志级别 |
|
||||||
|
| `LOG_FILE` | ❌ | (空) | 日志持久化文件路径;空=仅 stdout + Admin 内存日志页(重启清零)。compose 已默认设为 `/app/logs/app.log` 并挂 `applogs` 卷,滚动上限约 10MB×6 份 |
|
||||||
| `API_PORT` | ❌ | `8000` | API 服务端口映射 |
|
| `API_PORT` | ❌ | `8000` | API 服务端口映射 |
|
||||||
| `FRONTEND_PORT` | ❌ | `3000` | 前端服务端口映射 |
|
| `FRONTEND_PORT` | ❌ | `3000` | 前端服务端口映射 |
|
||||||
| `POSTGRES_USER` | ✅ | — | PostgreSQL 用户名 |
|
| `POSTGRES_USER` | ✅ | — | PostgreSQL 用户名 |
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ Profeto/
|
|||||||
│ ├── config.py # pydantic-settings 配置
|
│ ├── config.py # pydantic-settings 配置
|
||||||
│ ├── http_client.py # 共享 httpx 客户端
|
│ ├── http_client.py # 共享 httpx 客户端
|
||||||
│ ├── crypto.py # 对称加密(Fernet)与密码哈希
|
│ ├── crypto.py # 对称加密(Fernet)与密码哈希
|
||||||
│ ├── log_buffer.py # 内存日志缓冲(admin「系统日志」页)
|
│ ├── log_buffer.py # 日志基础设施:内存环形缓冲(admin 日志页) + 可选滚动文件持久化(LOG_FILE)
|
||||||
│ ├── runtime_config.py # 运行时配置(数据库优先,回落 .env)
|
│ ├── runtime_config.py # 运行时配置(数据库优先,回落 .env)
|
||||||
│ ├── scheduler.py # 定时任务调度器(cron 触发采集)
|
│ ├── scheduler.py # 定时任务调度器(cron 触发采集)
|
||||||
│ └── security_check.py # 生产启动安全校验(缺配置拒绝启动)
|
│ └── security_check.py # 生产启动安全校验(缺配置拒绝启动)
|
||||||
|
|||||||
+5
-58
@@ -11,48 +11,17 @@
|
|||||||
* - 未登录访问管理 → AdminLayout 门禁 → 登录页(不静默失败)
|
* - 未登录访问管理 → AdminLayout 门禁 → 登录页(不静默失败)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { BrowserRouter, Routes, Route, Navigate, Link } from 'react-router-dom'
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||||
|
import Masthead from './components/Masthead'
|
||||||
import Matches from './pages/Matches'
|
import Matches from './pages/Matches'
|
||||||
import Standings from './pages/Standings'
|
import Standings from './pages/Standings'
|
||||||
import { adminRoutes } from './admin/routes'
|
import { adminRoutes } from './admin/routes'
|
||||||
|
|
||||||
/** 报眉日期行 */
|
|
||||||
function dateLine(): string {
|
|
||||||
return new Date().toLocaleDateString('zh-CN', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
weekday: 'long',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function StandingsLayout({ children }: { children: React.ReactNode }) {
|
function StandingsLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-paper-50">
|
<div className="min-h-screen bg-paper-50">
|
||||||
<header className="masthead-rule">
|
<Masthead active="standings" />
|
||||||
<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">
|
<main className="mx-auto max-w-5xl px-5 py-6 sm:px-8 sm:py-8">
|
||||||
{children}
|
{children}
|
||||||
@@ -70,30 +39,8 @@ function StandingsLayout({ children }: { children: React.ReactNode }) {
|
|||||||
function HomePage() {
|
function HomePage() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-paper-50">
|
<div className="min-h-screen bg-paper-50">
|
||||||
{/* ── 报头:粗线 + 居中刊名 + 日期与分区链接 ── */}
|
{/* ── 报头:粗线 + 居中刊名 + 日期与分区链接(共用 Masthead) ── */}
|
||||||
<header className="masthead-rule">
|
<Masthead active="home" />
|
||||||
<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">
|
<main className="mx-auto max-w-5xl px-5 py-6 sm:px-8 sm:py-8">
|
||||||
<Matches />
|
<Matches />
|
||||||
|
|||||||
@@ -371,10 +371,14 @@ export function fetchIngestJob(jobId: string): Promise<IngestJob> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 最近采集任务列表(最新在前)
|
* 采集任务历史列表(GET /admin/ingest/jobs,最新在前)
|
||||||
*/
|
*/
|
||||||
export function fetchIngestJobs(limit = 20): Promise<IngestJob[]> {
|
export function fetchIngestJobs(params: { limit?: number; status?: string } = {}): Promise<IngestJob[]> {
|
||||||
return api.get<IngestJob[]>(`${API_BASE}/admin/ingest/jobs?limit=${limit}`)
|
const q = new URLSearchParams()
|
||||||
|
if (params.limit != null) q.set('limit', String(params.limit))
|
||||||
|
if (params.status) q.set('status', params.status)
|
||||||
|
const qs = q.toString()
|
||||||
|
return api.get<IngestJob[]>(`${API_BASE}/admin/ingest/jobs${qs ? `?${qs}` : ''}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+18
-17
@@ -20,18 +20,25 @@ export interface NavItem {
|
|||||||
hideFromSidebar?: boolean
|
hideFromSidebar?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 唯一的导航配置源。顺序 = 侧栏渲染顺序(命令面板分组内顺序与之相同)。 */
|
/** 唯一的导航配置源。顺序 = 侧栏渲染顺序(命令面板分组内顺序与之相同)。
|
||||||
|
*
|
||||||
|
* 分组按用户心智模型: 数据(往里灌) → 预测(算出来) → 系统(保证它活着)。
|
||||||
|
* 「预测历史/回测/评估」同属预测生命周期,不再挂在「数据流水线」名下。
|
||||||
|
*/
|
||||||
export const NAV_ITEMS: NavItem[] = [
|
export const NAV_ITEMS: NavItem[] = [
|
||||||
{ to: '/admin', label: '仪表盘', group: '概览', icon: 'chart' },
|
{ to: '/admin', label: '仪表盘', group: '概览', icon: 'chart' },
|
||||||
{ to: '/admin/collection', label: '数据采集', group: '数据流水线', icon: 'collection' },
|
// ── 数据:采集、核对、管线内部视图 ──
|
||||||
{ to: '/admin/data-completeness', label: '数据完整性', group: '数据流水线', icon: 'chart' },
|
{ to: '/admin/collection', label: '数据采集', group: '数据', icon: 'collection' },
|
||||||
{ to: '/admin/data-pipeline', label: '数据管线', group: '数据流水线', icon: 'chart', hideFromSidebar: true },
|
{ to: '/admin/data-completeness', label: '数据完整性', group: '数据', icon: 'eval' },
|
||||||
{ to: '/admin/predictions', label: '预测历史', group: '数据流水线', icon: 'logs' },
|
{ to: '/admin/data-pipeline', label: '数据管线', group: '数据', icon: 'chart', hideFromSidebar: true },
|
||||||
{ to: '/admin/backtest', label: '回测', group: '数据流水线', icon: 'repeat' },
|
// ── 预测:历史 → 回测 → 评估闭环 ──
|
||||||
{ to: '/admin/eval', label: '评估', group: '评估与监控', icon: 'eval' },
|
{ to: '/admin/predictions', label: '预测历史', group: '预测', icon: 'target' },
|
||||||
{ to: '/admin/monitoring', label: '监控', group: '评估与监控', icon: 'monitor' },
|
{ to: '/admin/backtest', label: '回测', group: '预测', icon: 'repeat' },
|
||||||
{ to: '/admin/settings', label: '设置', group: '系统', icon: 'settings' },
|
{ to: '/admin/eval', label: '评估', group: '预测', icon: 'eval' },
|
||||||
|
// ── 系统:活着吗、发生了什么、怎么调 ──
|
||||||
|
{ to: '/admin/monitoring', label: '监控', group: '系统', icon: 'monitor' },
|
||||||
{ to: '/admin/logs', label: '日志', group: '系统', icon: 'logs' },
|
{ to: '/admin/logs', label: '日志', group: '系统', icon: 'logs' },
|
||||||
|
{ to: '/admin/settings', label: '设置', group: '系统', icon: 'settings' },
|
||||||
]
|
]
|
||||||
|
|
||||||
/** 命令面板条目(原 NAV_PAGES 的唯一来源) */
|
/** 命令面板条目(原 NAV_PAGES 的唯一来源) */
|
||||||
@@ -42,11 +49,6 @@ export const ROUTE_LABELS: Record<string, string> = Object.fromEntries(
|
|||||||
NAV_ITEMS.map(i => [i.to, i.label]),
|
NAV_ITEMS.map(i => [i.to, i.label]),
|
||||||
)
|
)
|
||||||
|
|
||||||
/** 侧栏分组标题的历史显示名(仅侧栏使用;与面板分组名不同时在此映射) */
|
|
||||||
const SIDEBAR_GROUP_TITLES: Record<string, string> = {
|
|
||||||
系统: '系统设置',
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 侧栏分组(原 NAV_SECTIONS 的唯一来源)。
|
* 侧栏分组(原 NAV_SECTIONS 的唯一来源)。
|
||||||
* 仪表盘(group=概览)在 AdminLayout 中独立渲染于顶部,不进分组循环。
|
* 仪表盘(group=概览)在 AdminLayout 中独立渲染于顶部,不进分组循环。
|
||||||
@@ -55,13 +57,12 @@ export const NAV_SECTIONS = (() => {
|
|||||||
const sidebarItems = NAV_ITEMS.filter(i => !i.hideFromSidebar && i.group !== '概览')
|
const sidebarItems = NAV_ITEMS.filter(i => !i.hideFromSidebar && i.group !== '概览')
|
||||||
const titles: string[] = []
|
const titles: string[] = []
|
||||||
for (const i of sidebarItems) {
|
for (const i of sidebarItems) {
|
||||||
const title = SIDEBAR_GROUP_TITLES[i.group] ?? i.group
|
if (!titles.includes(i.group)) titles.push(i.group)
|
||||||
if (!titles.includes(title)) titles.push(title)
|
|
||||||
}
|
}
|
||||||
return titles.map(title => ({
|
return titles.map(title => ({
|
||||||
title,
|
title,
|
||||||
items: sidebarItems
|
items: sidebarItems
|
||||||
.filter(i => (SIDEBAR_GROUP_TITLES[i.group] ?? i.group) === title)
|
.filter(i => i.group === title)
|
||||||
.map(({ to, label, icon }) => ({ to, label, icon })),
|
.map(({ to, label, icon }) => ({ to, label, icon })),
|
||||||
}))
|
}))
|
||||||
})()
|
})()
|
||||||
|
|||||||
@@ -11,16 +11,17 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||||
import { triggerCollection, fetchLeagues, fetchIngestJob } from '../dal'
|
import { triggerCollection, fetchLeagues, fetchIngestJob, fetchIngestJobs } from '../dal'
|
||||||
import type { IngestJob, League } from '../types'
|
import type { IngestJob, League } from '../types'
|
||||||
import type { CollectionRequest } from '../types'
|
import type { CollectionRequest } from '../types'
|
||||||
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
|
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
|
||||||
|
|
||||||
|
// 图标用与全站一致的几何字符(Dashboard 工作流卡同款),不混用 emoji
|
||||||
const TASKS = [
|
const TASKS = [
|
||||||
{ value: 'events', label: '比赛数据', desc: '赛程 / 比分 / 未开赛安排', icon: '⚽' },
|
{ value: 'events', label: '比赛数据', desc: '赛程 / 比分 / 未开赛安排', icon: '◈' },
|
||||||
{ value: 'standings', label: '积分榜', desc: '联赛排名 / 积分 / xG差 / 近期走势', icon: '🏆' },
|
{ value: 'standings', label: '积分榜', desc: '联赛排名 / 积分 / xG差 / 近期走势', icon: '◇' },
|
||||||
{ value: 'stats', label: '统计回填', desc: '已完赛比赛的 xG / 射门 / 控球等详细统计', icon: '📊' },
|
{ value: 'stats', label: '统计回填', desc: '已完赛比赛的 xG / 射门 / 控球等详细统计', icon: '▤' },
|
||||||
{ value: 'all', label: '全量采集', desc: '依次采集比赛 + 积分榜 + 统计回填', icon: '⏵⏵' },
|
{ value: 'all', label: '全量采集', desc: '依次采集比赛 + 积分榜 + 统计回填', icon: '⇉' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
type TaskUIStatus = 'idle' | 'running' | 'done' | 'error'
|
type TaskUIStatus = 'idle' | 'running' | 'done' | 'error'
|
||||||
@@ -73,6 +74,16 @@ export default function CollectionPage() {
|
|||||||
|
|
||||||
useEffect(() => () => stopPolling(), [stopPolling])
|
useEffect(() => () => stopPolling(), [stopPolling])
|
||||||
|
|
||||||
|
// ── 最近任务历史(GET /admin/ingest/jobs,最新在前) ──
|
||||||
|
// 声明须在 startJobPolling 之前(其终态回调会刷新历史)
|
||||||
|
const [recentJobs, setRecentJobs] = useState<IngestJob[] | null>(null)
|
||||||
|
const loadRecentJobs = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setRecentJobs(await fetchIngestJobs({ limit: 10 }))
|
||||||
|
} catch { /* 历史列表失败不影响主流程 */ }
|
||||||
|
}, [])
|
||||||
|
useEffect(() => { loadRecentJobs() }, [loadRecentJobs])
|
||||||
|
|
||||||
const startJobPolling = useCallback((id: string) => {
|
const startJobPolling = useCallback((id: string) => {
|
||||||
stopPolling()
|
stopPolling()
|
||||||
const tick = async () => {
|
const tick = async () => {
|
||||||
@@ -82,12 +93,13 @@ export default function CollectionPage() {
|
|||||||
if (TERMINAL_STATUSES.has(job.status)) {
|
if (TERMINAL_STATUSES.has(job.status)) {
|
||||||
setTaskStatus(job.status === 'success' ? 'done' : 'error')
|
setTaskStatus(job.status === 'success' ? 'done' : 'error')
|
||||||
stopPolling()
|
stopPolling()
|
||||||
|
loadRecentJobs() // 终态后刷新历史列表
|
||||||
}
|
}
|
||||||
} catch { /* 单次轮询失败不影响后续 */ }
|
} catch { /* 单次轮询失败不影响后续 */ }
|
||||||
}
|
}
|
||||||
tick()
|
tick()
|
||||||
pollRef.current = setInterval(tick, 3_000)
|
pollRef.current = setInterval(tick, 3_000)
|
||||||
}, [stopPolling])
|
}, [stopPolling, loadRecentJobs])
|
||||||
|
|
||||||
const isEventsTask = task === 'events' || task === 'all'
|
const isEventsTask = task === 'events' || task === 'all'
|
||||||
|
|
||||||
@@ -183,9 +195,9 @@ export default function CollectionPage() {
|
|||||||
key={t.value}
|
key={t.value}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTask(t.value)}
|
onClick={() => setTask(t.value)}
|
||||||
className={`rounded-lg border px-3 py-2 text-left text-xs transition-colors ${
|
className={`border px-3 py-2 text-left text-xs transition-colors ${
|
||||||
task === t.value
|
task === t.value
|
||||||
? 'border-brand-500 bg-brand-50 text-brand-700'
|
? 'border-press bg-press-wash text-press'
|
||||||
: 'border-ink-200 text-ink-600 hover:border-ink-300'
|
: 'border-ink-200 text-ink-600 hover:border-ink-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -321,8 +333,8 @@ export default function CollectionPage() {
|
|||||||
)}
|
)}
|
||||||
{taskStatus === 'done' && (
|
{taskStatus === 'done' && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2 text-xs text-emerald-700">
|
<div className="flex items-center gap-2 text-xs text-ok-700">
|
||||||
<span className="inline-block h-2 w-2 rounded-full bg-emerald-500" />
|
<span className="inline-block h-2 w-2 rounded-full bg-ok-500" />
|
||||||
<span>采集完成{jobId ? `(job ${jobId.slice(0, 8)}…)` : ''}</span>
|
<span>采集完成{jobId ? `(job ${jobId.slice(0, 8)}…)` : ''}</span>
|
||||||
</div>
|
</div>
|
||||||
{summary && <p className="text-2xs text-ink-500">{summary.detail}</p>}
|
{summary && <p className="text-2xs text-ink-500">{summary.detail}</p>}
|
||||||
@@ -345,6 +357,41 @@ export default function CollectionPage() {
|
|||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* 最近任务历史 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
title="最近任务"
|
||||||
|
description="后台采集任务执行历史(最新在前)"
|
||||||
|
action={<button onClick={loadRecentJobs} className="btn btn-sm">刷新</button>}
|
||||||
|
/>
|
||||||
|
<CardBody className="px-0">
|
||||||
|
{recentJobs === null ? (
|
||||||
|
<p className="px-4 py-2 text-xs text-ink-400 sm:px-5">加载中…</p>
|
||||||
|
) : recentJobs.length === 0 ? (
|
||||||
|
<p className="px-4 py-2 text-xs text-ink-400 sm:px-5">还没有任务记录,触发一次采集后这里会出现历史。</p>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
{recentJobs.map(j => {
|
||||||
|
const st = j.status
|
||||||
|
const dot = st === 'success' ? 'bg-ink-900' : st === 'failed' ? 'bg-press' : 'bg-warn-500 animate-pulse'
|
||||||
|
const label = st === 'success' ? '完成' : st === 'failed' ? '失败' : st === 'running' ? '执行中' : '排队中'
|
||||||
|
return (
|
||||||
|
<div key={j.id} className="flex items-center gap-3 border-b border-ink-200 px-4 py-2.5 last:border-b-0 sm:px-5">
|
||||||
|
<span aria-hidden="true" className={`inline-block h-1.5 w-1.5 flex-shrink-0 ${dot}`} />
|
||||||
|
<Badge status={st === 'failed' ? 'error' : 'info'}>{j.task}</Badge>
|
||||||
|
<span className="text-2xs text-ink-600">{label}</span>
|
||||||
|
<span className="ml-auto text-right text-2xs tabular-nums text-ink-400">
|
||||||
|
{j.created_at && new Date(j.created_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false })}
|
||||||
|
{' '}{jobSummary(j)?.detail ?? (j.error ? j.error.slice(0, 40) : '')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* 数据源说明 */}
|
{/* 数据源说明 */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader title="采集任务说明" />
|
<CardHeader title="采集任务说明" />
|
||||||
|
|||||||
@@ -1,267 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,137 +1,199 @@
|
|||||||
/**
|
/**
|
||||||
* Admin 后台 - 仪表盘(报刊风)
|
* Admin 后台 - 仪表盘(报刊风·待办驱动)
|
||||||
*
|
*
|
||||||
* 展示:
|
* 设计原则:单人管理员的注意力应该花在「现在需要处理什么」上,
|
||||||
* - 数据流水线状态(采集 → 预测 → 评估,每步的实际数据量)
|
* 而不是扫描一堆常驻数字。
|
||||||
* - 近期预测活动(24h / 7d / 总计)
|
* - 顶部待办行:只有真有待办才出现(死信 / 缺数据联赛 / 可结算预测),
|
||||||
* - 快捷操作入口(带工作流引导)
|
* 每项直达处理页面 —— 引导出现在需要时,而不是永远占着版面
|
||||||
|
* - 三步工作流卡只在库里还没有比赛时显示(首次使用引导)
|
||||||
|
* - 数据概览合并为一卡:预测活动 + 各表数据量
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
import { fetchAdminStats, fetchIngestStatus, fetchDashboard } from '../dal'
|
import { Link } from 'react-router-dom'
|
||||||
|
import { fetchAdminStats, fetchIngestStatus, fetchDashboard, fetchIngestFailures, fetchDataCompleteness, fetchPredictions } from '../dal'
|
||||||
|
import type { DataCompletenessResponse, IngestFailureItem } from '../dal'
|
||||||
import type { AdminStats, IngestSourceStatus, DashboardStats } from '../types'
|
import type { AdminStats, IngestSourceStatus, DashboardStats } from '../types'
|
||||||
import { Card, CardBody, CardHeader, Alert, SkeletonBlock } from '../components'
|
import { Card, CardBody, CardHeader, SkeletonBlock } from '../components'
|
||||||
|
|
||||||
/** 工作流步骤卡片 */
|
/** 工作流引导(仅首次使用——库里还没有比赛时显示) */
|
||||||
const STEPS = [
|
const STEPS = [
|
||||||
{ to: '/admin/collection', step: '1', title: '采集数据', desc: 'bzzoiro: 赛程 / 积分榜 / 比赛统计(xG、射门、控球等)', icon: '◈' },
|
{ to: '/admin/collection', step: '1', title: '采集数据', desc: 'bzzoiro: 赛程 / 积分榜 / 比赛统计(xG、射门、控球等)' },
|
||||||
{ to: '/admin/predictions', step: '2', title: '运行预测', desc: '调 LLM 多专家生成比分预测', icon: '◆' },
|
{ to: '/admin/predictions', step: '2', title: '预测与结算', desc: '在前台比赛详情页发起预测;赛后回到「预测历史」结算' },
|
||||||
{ to: '/admin/eval', step: '3', title: '评估准确率', desc: '结算后查看 1X2 命中率与校准度', icon: '◈' },
|
{ to: '/admin/eval', step: '3', title: '评估准确率', desc: '结算后查看 1X2 命中率与校准度' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
interface TodoItem {
|
||||||
|
key: string
|
||||||
|
count: number
|
||||||
|
label: string
|
||||||
|
to: string
|
||||||
|
/** 无上限确认时数字近似,展示为 N+ */
|
||||||
|
approx?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const [stats, setStats] = useState<AdminStats | null>(null)
|
const [stats, setStats] = useState<AdminStats | null>(null)
|
||||||
const [ingest, setIngest] = useState<IngestSourceStatus[]>([])
|
const [ingest, setIngest] = useState<IngestSourceStatus[]>([])
|
||||||
const [dash, setDash] = useState<DashboardStats | null>(null)
|
const [dash, setDash] = useState<DashboardStats | null>(null)
|
||||||
|
const [failures, setFailures] = useState<IngestFailureItem[]>([])
|
||||||
|
const [completeness, setCompleteness] = useState<DataCompletenessResponse | null>(null)
|
||||||
|
const [recentPreds, setRecentPreds] = useState<Array<{ settled?: boolean; actual_home_goals?: number | null; actual_away_goals?: number | null }>>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const [s, i, d] = await Promise.allSettled([
|
// 各数据源独立容错:单接口失败只降级对应卡片,不拖垮整页
|
||||||
|
const [s, i, d, f, c, p] = await Promise.allSettled([
|
||||||
fetchAdminStats(),
|
fetchAdminStats(),
|
||||||
fetchIngestStatus(),
|
fetchIngestStatus(),
|
||||||
fetchDashboard(),
|
fetchDashboard(),
|
||||||
|
fetchIngestFailures(),
|
||||||
|
fetchDataCompleteness(),
|
||||||
|
fetchPredictions(100),
|
||||||
])
|
])
|
||||||
if (s.status === 'fulfilled') setStats(s.value)
|
if (s.status === 'fulfilled') setStats(s.value)
|
||||||
if (i.status === 'fulfilled') setIngest(i.value.sources)
|
if (i.status === 'fulfilled') setIngest(i.value.sources)
|
||||||
if (d.status === 'fulfilled') setDash(d.value)
|
if (d.status === 'fulfilled') setDash(d.value)
|
||||||
|
if (f.status === 'fulfilled') setFailures(f.value)
|
||||||
|
if (c.status === 'fulfilled') setCompleteness(c.value)
|
||||||
|
if (p.status === 'fulfilled' && Array.isArray(p.value)) setRecentPreds(p.value)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => { load() }, [load])
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
// ── 待办计算 ──
|
||||||
|
const deadLetterCount = failures.filter(f => f.status !== 'resolved').length
|
||||||
|
const missingStatsLeagues = completeness?.leagues.filter(
|
||||||
|
l => l.matches.finished > 0 && l.stats.rows === 0,
|
||||||
|
).length ?? 0
|
||||||
|
const missingStandingsLeagues = completeness?.leagues.filter(
|
||||||
|
l => l.matches.total > 0 && l.standings.rows === 0,
|
||||||
|
).length ?? 0
|
||||||
|
const missingLeagues = missingStatsLeagues + missingStandingsLeagues
|
||||||
|
// 近 100 条内「比赛已出比分但未结算」的预测(列表接口有上限,数字近似)
|
||||||
|
const settleable = recentPreds.filter(
|
||||||
|
p => !p.settled && p.actual_home_goals != null && p.actual_away_goals != null,
|
||||||
|
).length
|
||||||
|
|
||||||
|
const todos: TodoItem[] = [
|
||||||
|
deadLetterCount > 0 && { key: 'deadletter', count: deadLetterCount, label: '采集失败待处理', to: '/admin/data-pipeline' },
|
||||||
|
missingLeagues > 0 && { key: 'completeness', count: missingLeagues, label: '联赛数据缺口', to: '/admin/data-completeness' },
|
||||||
|
settleable > 0 && { key: 'settle', count: settleable, label: '预测可结算', to: '/admin/predictions', approx: true },
|
||||||
|
].filter((t): t is TodoItem => t !== false)
|
||||||
|
|
||||||
const sourceByName = Object.fromEntries(ingest.map(s => [s.name, s]))
|
const sourceByName = Object.fromEntries(ingest.map(s => [s.name, s]))
|
||||||
const bzzoiro = sourceByName['bzzoiro']
|
const bzzoiro = sourceByName['bzzoiro']
|
||||||
|
const hasMatches = (stats?.matches?.total ?? 0) > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* ── 工作流引导(采集 → 预测 → 评估) ── */}
|
{/* ── 待办行:只有真有待办才出现 ── */}
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
{loading ? (
|
||||||
{STEPS.map((s, i) => (
|
<SkeletonBlock className="h-14 w-full" />
|
||||||
<a
|
) : todos.length > 0 ? (
|
||||||
key={s.to}
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
href={s.to}
|
{todos.map(t => (
|
||||||
className="group border border-ink-300 p-4 transition-colors hover:border-ink-900 hover:bg-paper-100"
|
<Link
|
||||||
>
|
key={t.key}
|
||||||
<div className="flex items-center gap-2.5">
|
to={t.to}
|
||||||
<span className="flex h-7 w-7 items-center justify-center border border-ink-900 font-serif text-xs font-bold text-ink-900">
|
className="group flex items-center gap-3 border border-press bg-press-wash/40 px-4 py-3 transition-colors hover:bg-press-wash"
|
||||||
{s.step}
|
>
|
||||||
|
<span className="font-serif text-2xl font-bold tabular-nums text-press">
|
||||||
|
{t.count}{t.approx ? '+' : ''}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-serif text-sm font-bold text-ink-900">{s.title}</span>
|
<span className="text-xs text-ink-700">{t.label}</span>
|
||||||
<span className="ml-auto text-ink-300 transition-colors group-hover:text-press" aria-hidden="true">→</span>
|
<span className="ml-auto text-press transition-transform group-hover:translate-x-0.5" aria-hidden="true">→</span>
|
||||||
</div>
|
</Link>
|
||||||
<p className="mt-2 text-2xs leading-relaxed text-ink-500">{s.desc}</p>
|
))}
|
||||||
{i < STEPS.length - 1 && <span className="sr-only">下一步</span>}
|
</div>
|
||||||
</a>
|
) : (
|
||||||
))}
|
<p className="flex items-center gap-2 border-b border-ink-200 pb-3 text-2xs text-ink-400">
|
||||||
</div>
|
<span className="inline-block h-1.5 w-1.5 bg-ink-900" aria-hidden="true" />
|
||||||
|
流水线无待办:没有失败记录、数据缺口或待结算预测。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── 数据源健康一览 ── */}
|
{/* ── 三步引导:仅首次使用(库里还没有比赛)时显示 ── */}
|
||||||
|
{!loading && !hasMatches && (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
{STEPS.map(s => (
|
||||||
|
<Link
|
||||||
|
key={s.to}
|
||||||
|
to={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>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── 数据源:单源一行即足,不装成列表 ── */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader
|
<CardHeader
|
||||||
title="数据源健康"
|
title="数据源"
|
||||||
description="各源最近采集时间与数据量(只读快照,详细配置见「数据源」页)"
|
description="bzzoiro 最近采集情况,Key 与轮换配置见「设置 → 数据源」"
|
||||||
/>
|
/>
|
||||||
<CardBody className="px-0">
|
<CardBody className="px-0">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="space-y-2 px-4 sm:px-5">
|
<div className="px-4 sm:px-5"><SkeletonBlock className="h-8 w-full" /></div>
|
||||||
{[1, 2, 3].map(i => <SkeletonBlock key={i} className="h-8 w-full" />)}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div>
|
(() => {
|
||||||
{[
|
const st = bzzoiro
|
||||||
{ name: 'bzzoiro', label: 'Bzzoiro', st: bzzoiro },
|
const hasData = st && st.recent_count > 0
|
||||||
].map(({ name, label, st }) => {
|
const keyOk = st?.key_configured !== false
|
||||||
const hasData = st && st.recent_count > 0
|
return (
|
||||||
const keyOk = st?.key_configured !== false
|
<div className="flex items-center justify-between px-4 py-2.5 sm:px-5">
|
||||||
return (
|
<span className="text-xs font-medium text-ink-700">Bzzoiro</span>
|
||||||
<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="flex items-center gap-3 text-2xs">
|
||||||
<span className="text-xs font-medium text-ink-700">{label}</span>
|
{hasData ? (
|
||||||
<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>
|
||||||
<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>
|
||||||
) : keyOk ? (
|
) : (
|
||||||
<span className="text-ink-400">无数据</span>
|
<Link to="/admin/settings?tab=datasource" className="text-press underline underline-offset-2">未配置 Key,去设置</Link>
|
||||||
) : (
|
)}
|
||||||
<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>
|
||||||
<span
|
</div>
|
||||||
aria-hidden="true"
|
)
|
||||||
className={`inline-block h-1.5 w-1.5 ${hasData && keyOk ? 'bg-ink-900' : 'bg-press'}`}
|
})()
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* ── 近期预测活动 ── */}
|
{/* ── 数据概览:预测活动 + 数据量,合并一卡 ── */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader title="近期预测活动" description="预测 API 的调用量统计" />
|
<CardHeader title="数据概览" description="预测调用量与各表数据量" />
|
||||||
<CardBody>
|
<CardBody>
|
||||||
{stats ? (
|
{stats ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-3 gap-4 text-center">
|
<div className="grid grid-cols-3 gap-4 text-center">
|
||||||
<div>
|
<div>
|
||||||
<div className="font-serif text-3xl font-bold tabular-nums text-ink-900">{stats.predictions.last_24h}</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 className="mt-1 text-2xs text-ink-400">预测 · 近 24 小时</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-serif text-3xl font-bold tabular-nums text-ink-900">{stats.predictions.last_7d}</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 className="mt-1 text-2xs text-ink-400">预测 · 近 7 天</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-serif text-3xl font-bold tabular-nums text-ink-900">{stats.predictions.total}</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 className="mt-1 text-2xs text-ink-400">预测 · 累计</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* F3 修复: 真实比赛计数(非 limit=100 近似) */}
|
|
||||||
<div className="grid grid-cols-4 gap-3 border-t border-ink-200 pt-3 text-center">
|
<div className="grid grid-cols-4 gap-3 border-t border-ink-200 pt-3 text-center">
|
||||||
<div>
|
<div>
|
||||||
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">{stats.matches?.total ?? 0}</div>
|
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">{stats.matches?.total ?? 0}</div>
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function pctColor(pct: number): string {
|
function pctColor(pct: number): string {
|
||||||
if (pct >= 80) return 'bg-emerald-500'
|
if (pct >= 80) return 'bg-ok-500'
|
||||||
if (pct >= 50) return 'bg-amber-500'
|
if (pct >= 50) return 'bg-warn-500'
|
||||||
return 'bg-rose-500'
|
return 'bg-bad-500'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 根据问题描述生成可操作的修复链接 */
|
/** 根据问题描述生成可操作的修复链接 */
|
||||||
|
|||||||
@@ -1,376 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,275 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -85,7 +85,7 @@ export default function LogsPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="系统日志"
|
title="系统日志"
|
||||||
description="应用运行日志(登录、配置变更、采集、预测、异常等)。内存缓冲最近 2000 条,重启后清零。"
|
description="应用运行日志(登录、配置变更、采集、预测、异常等)。内存缓冲最近 2000 条,重启后清零;若后端已配置 LOG_FILE,完整日志同时滚动写入服务器文件(单文件 10MB × 5 份),可登录宿主机查看。"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{error && <Alert kind="error" title="无法加载日志" message={error} />}
|
{error && <Alert kind="error" title="无法加载日志" message={error} />}
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export default function PredictionHistoryPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardBody className="text-center">
|
<CardBody className="text-center">
|
||||||
<p className="text-2xl font-bold text-emerald-700">{hitCount}</p>
|
<p className="text-2xl font-bold text-ok-700">{hitCount}</p>
|
||||||
<p className="text-xs text-ink-500">命中</p>
|
<p className="text-xs text-ink-500">命中</p>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -194,7 +194,7 @@ export default function PredictionHistoryPage() {
|
|||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
{p.pred_1x2 ? (
|
{p.pred_1x2 ? (
|
||||||
<span className={`inline-block border px-1.5 py-0.5 text-2xs ${
|
<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 === true ? 'border-ok-300 text-ok-700 bg-ok-50' :
|
||||||
predHit === false ? 'border-press text-press bg-press-wash' :
|
predHit === false ? 'border-press text-press bg-press-wash' :
|
||||||
'border-ink-200 text-ink-600'
|
'border-ink-200 text-ink-600'
|
||||||
}`}>
|
}`}>
|
||||||
|
|||||||
@@ -1,363 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { useSearchParams } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
fetchSettings, updateSetting, clearSetting,
|
fetchSettings, updateSetting, clearSetting,
|
||||||
testLLMConnection, fetchLLMUsageStats, fetchLLMModels,
|
testLLMConnection, fetchLLMUsageStats, fetchLLMModels,
|
||||||
@@ -26,7 +27,24 @@ import AgentLLMCard from '../AgentLLMCard'
|
|||||||
const DATA_SOURCE_KEYS = ['BZZOIRO_KEY', 'BZZOIRO_BASE']
|
const DATA_SOURCE_KEYS = ['BZZOIRO_KEY', 'BZZOIRO_BASE']
|
||||||
const LLM_KEYS = ['LLM_API_KEY', 'LLM_BASE_URL', 'LLM_MODEL']
|
const LLM_KEYS = ['LLM_API_KEY', 'LLM_BASE_URL', 'LLM_MODEL']
|
||||||
|
|
||||||
|
/** 设置页分区(tab)。低频/高危操作靠后:安全放最后。 */
|
||||||
|
const TABS = [
|
||||||
|
{ id: 'datasource', label: '数据源' },
|
||||||
|
{ id: 'llm', label: '大语言模型' },
|
||||||
|
{ id: 'schedules', label: '定时任务' },
|
||||||
|
{ id: 'security', label: '登录认证' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
type TabId = (typeof TABS)[number]['id']
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
|
// tab 状态写入 URL(?tab=llm),可深链直达、刷新保持
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const rawTab = searchParams.get('tab')
|
||||||
|
const tab: TabId = TABS.some(t => t.id === rawTab) ? (rawTab as TabId) : 'datasource'
|
||||||
|
const setTab = (id: TabId) =>
|
||||||
|
setSearchParams(id === 'datasource' ? {} : { tab: id }, { replace: true })
|
||||||
|
|
||||||
const [allSettings, setAllSettings] = useState<DataSourceSetting[]>([])
|
const [allSettings, setAllSettings] = useState<DataSourceSetting[]>([])
|
||||||
const [settingsLoading, setSettingsLoading] = useState(true)
|
const [settingsLoading, setSettingsLoading] = useState(true)
|
||||||
const [editingKey, setEditingKey] = useState<string | null>(null)
|
const [editingKey, setEditingKey] = useState<string | null>(null)
|
||||||
@@ -229,12 +247,31 @@ export default function SettingsPage() {
|
|||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="系统设置"
|
title="系统设置"
|
||||||
description="数据源、LLM、认证等全部配置。保存到数据库并立即生效,优先于 .env。"
|
description="数据源、LLM、定时任务与登录认证。保存到数据库并立即生效,优先于 .env。"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* ── 分区 tab(状态在 URL 上,可深链) ── */}
|
||||||
|
<div className="-mt-4 flex gap-5 overflow-x-auto border-b border-ink-200" role="tablist" aria-label="设置分区">
|
||||||
|
{TABS.map(t => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={tab === t.id}
|
||||||
|
onClick={() => setTab(t.id)}
|
||||||
|
className={`-mb-px flex-shrink-0 border-b-2 pb-2 text-sm transition-colors ${
|
||||||
|
tab === t.id
|
||||||
|
? 'border-press font-bold text-press'
|
||||||
|
: 'border-transparent text-ink-500 hover:text-ink-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ── 1. 数据源 ── */}
|
{/* ── 1. 数据源 ── */}
|
||||||
|
{tab === 'datasource' && (
|
||||||
<section>
|
<section>
|
||||||
<h2 className="section-head mb-3">数据源</h2>
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader
|
<CardHeader
|
||||||
@@ -280,11 +317,11 @@ export default function SettingsPage() {
|
|||||||
return (
|
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 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">
|
<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={`inline-block h-2 w-2 rounded-full ${isBlocked ? 'bg-warn-500' : 'bg-ok-500'}`} />
|
||||||
<span className="font-mono text-xs text-ink-700">{k.masked}</span>
|
<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>}
|
{i === keyRing.active_index && <span className="rounded bg-ink-900 px-1.5 py-0.5 text-2xs text-paper-500">当前</span>}
|
||||||
</div>
|
</div>
|
||||||
<span className={`text-2xs tabular-nums ${isBlocked ? 'text-amber-600' : 'text-ink-400'}`}>
|
<span className={`text-2xs tabular-nums ${isBlocked ? 'text-warn-700' : 'text-ink-400'}`}>
|
||||||
{isBlocked ? `冷却中 ${k.blocked_remaining.toFixed(0)}s` : '可用'}
|
{isBlocked ? `冷却中 ${k.blocked_remaining.toFixed(0)}s` : '可用'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -298,10 +335,11 @@ export default function SettingsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── 2. LLM ── */}
|
{/* ── 2. LLM ── */}
|
||||||
|
{tab === 'llm' && (
|
||||||
<section>
|
<section>
|
||||||
<h2 className="section-head mb-3">大语言模型</h2>
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader
|
<CardHeader
|
||||||
@@ -372,10 +410,11 @@ export default function SettingsPage() {
|
|||||||
<AgentLLMCard />
|
<AgentLLMCard />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── 3. 认证 ── */}
|
{/* ── 3. 认证 ── */}
|
||||||
|
{tab === 'security' && (
|
||||||
<section>
|
<section>
|
||||||
<h2 className="section-head mb-3">登录认证</h2>
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader title="修改密码" description="密码即会话签名密钥,修改后所有已登录会话失效,需重新登录" />
|
<CardHeader title="修改密码" description="密码即会话签名密钥,修改后所有已登录会话失效,需重新登录" />
|
||||||
<CardBody>
|
<CardBody>
|
||||||
@@ -413,10 +452,11 @@ export default function SettingsPage() {
|
|||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── 4. 定时任务 ── */}
|
{/* ── 4. 定时任务 ── */}
|
||||||
|
{tab === 'schedules' && (
|
||||||
<section>
|
<section>
|
||||||
<h2 className="section-head mb-3">定时任务</h2>
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader
|
<CardHeader
|
||||||
title="采集调度"
|
title="采集调度"
|
||||||
@@ -449,7 +489,7 @@ export default function SettingsPage() {
|
|||||||
<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 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-1 space-y-1">
|
||||||
<div className="flex items-center gap-2">
|
<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={`inline-block h-2 w-2 rounded-full ${s.enabled ? 'bg-ok-500' : 'bg-ink-300'}`} />
|
||||||
<span className="text-xs font-medium text-ink-800">{s.id}</span>
|
<span className="text-xs font-medium text-ink-800">{s.id}</span>
|
||||||
<Badge status={s.enabled ? 'success' : 'muted'}>{s.task}</Badge>
|
<Badge status={s.enabled ? 'success' : 'muted'}>{s.task}</Badge>
|
||||||
</div>
|
</div>
|
||||||
@@ -491,6 +531,7 @@ export default function SettingsPage() {
|
|||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* 回到顶部浮动按钮(前台两页共用,此前 Matches/Standings 各复制一份)。
|
||||||
|
* 方角纸片风:去掉早期版本的 rounded-full + shadow-lg,与全站方角
|
||||||
|
* 无阴影语言对齐;出现/隐藏仅动画 transform 与 opacity。
|
||||||
|
*/
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
export default function BackTop({ threshold = 300 }: { threshold?: number }) {
|
||||||
|
const [show, setShow] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScroll = () => setShow(window.scrollY > threshold)
|
||||||
|
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||||
|
return () => window.removeEventListener('scroll', handleScroll)
|
||||||
|
}, [threshold])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
||||||
|
className={`fixed bottom-6 right-6 z-40 flex h-10 w-10 items-center justify-center border border-ink-300 bg-paper-50 text-ink-600 transition-all duration-300 hover:border-ink-900 hover:bg-ink-900 hover:text-paper-50 ${
|
||||||
|
show ? 'translate-y-0 opacity-100' : 'pointer-events-none translate-y-4 opacity-0'
|
||||||
|
}`}
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* 报头组件(前台共用)。
|
||||||
|
*
|
||||||
|
* 此前 HomePage 与 StandingsLayout 各自复制一份报头,导航项已经漂移
|
||||||
|
* (首页有「积分榜」、积分榜页有「比赛/预测」),且无当前页高亮。
|
||||||
|
* 现在统一为 Masthead:
|
||||||
|
* - active 声明当前版面,对应导航项加印报红高亮 + aria-current
|
||||||
|
* - 「管理」入口改用与全站一致的线条 SVG(替代字符 ⚙)
|
||||||
|
* - 管理类入口带 title 提示,避免访客被无声踢到登录页
|
||||||
|
* 页脚文案各页不同,仍由调用方自行渲染。
|
||||||
|
*/
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
|
export type MastheadActive = 'home' | 'standings'
|
||||||
|
|
||||||
|
function GearIcon() {
|
||||||
|
return (
|
||||||
|
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MastheadLink({
|
||||||
|
to,
|
||||||
|
active = false,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
to: string
|
||||||
|
/** 当前版面才高亮;管理类入口无高亮概念 */
|
||||||
|
active?: boolean
|
||||||
|
title?: string
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={to}
|
||||||
|
title={title}
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
|
className={`transition-colors ${
|
||||||
|
active ? 'font-medium text-press' : 'text-ink-500 hover:text-press'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Masthead({ active }: { active: MastheadActive }) {
|
||||||
|
return (
|
||||||
|
<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>{new Date().toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' })}</span>
|
||||||
|
<nav className="flex items-center gap-4" aria-label="页面导航">
|
||||||
|
<MastheadLink to="/" active={active === 'home'}>比赛 / 预测</MastheadLink>
|
||||||
|
<MastheadLink to="/standings" active={active === 'standings'}>积分榜</MastheadLink>
|
||||||
|
{/* 管理类页面对访客需要登录,入口处先说明,避免无声跳登录页 */}
|
||||||
|
<MastheadLink to="/admin/eval" title="评估页面向管理员开放,需登录">评估</MastheadLink>
|
||||||
|
<MastheadLink to="/admin" title="管理后台,需登录">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<GearIcon /> 管理
|
||||||
|
</span>
|
||||||
|
</MastheadLink>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -103,6 +103,28 @@
|
|||||||
.btn-danger {
|
.btn-danger {
|
||||||
@apply border-press bg-transparent text-press hover:bg-press hover:text-paper-50;
|
@apply border-press bg-transparent text-press hover:bg-press hover:text-paper-50;
|
||||||
}
|
}
|
||||||
|
/* 描边确认按钮:次级动作中需要比默认 btn 更明确轮廓的场合
|
||||||
|
(此前三处使用但从未定义,样式静默失效) */
|
||||||
|
.btn-outline {
|
||||||
|
@apply border-ink-900 bg-transparent text-ink-900
|
||||||
|
hover:border-press hover:bg-press-wash hover:text-press-dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 弹窗入场:遮罩淡入 + 面板上浮(仅 opacity/transform,GPU 友好) ── */
|
||||||
|
.modal-overlay-enter {
|
||||||
|
animation: modal-fade 0.18s ease-out both;
|
||||||
|
}
|
||||||
|
.modal-panel-enter {
|
||||||
|
animation: modal-rise 0.2s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
|
}
|
||||||
|
@keyframes modal-fade {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes modal-rise {
|
||||||
|
from { opacity: 0; transform: translateY(12px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
/* ── 统一空态 ── */
|
/* ── 统一空态 ── */
|
||||||
.empty-state {
|
.empty-state {
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* P0-00: HTTP client method/body/headers 可信度测试。
|
||||||
|
* 运行: node --experimental-strip-types frontend/src/lib/http.test.ts
|
||||||
|
*
|
||||||
|
* 最小环境 polyfill:Node 22 自带 fetch/AbortController,本测试不触发 401 路径,
|
||||||
|
* 故 window.dispatchEvent 不会被调用,无需完整 DOM。
|
||||||
|
*/
|
||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
|
// 最小浏览器环境 polyfill(仅覆盖 http.ts 在 happy path 用到的全局)
|
||||||
|
const store: Record<string, string> = {}
|
||||||
|
// @ts-expect-error 测试用最小 window stub
|
||||||
|
globalThis.window = {
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
localStorage: {
|
||||||
|
getItem: (k: string) => store[k] ?? null,
|
||||||
|
setItem: (k: string, v: string) => { store[k] = v },
|
||||||
|
removeItem: (k: string) => { delete store[k] },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 捕获每次 fetch 的入参供断言
|
||||||
|
let lastInit: RequestInit | undefined
|
||||||
|
globalThis.fetch = async (_url: string, init?: RequestInit) => {
|
||||||
|
lastInit = init
|
||||||
|
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'content-type': 'application/json' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { http } = await import('./http.ts')
|
||||||
|
|
||||||
|
test('GET: method=GET, 无 body, 无 Content-Type', async () => {
|
||||||
|
await http.get('/api/v1/matches')
|
||||||
|
assert.equal(lastInit?.method, 'GET')
|
||||||
|
assert.equal(lastInit?.body, undefined)
|
||||||
|
assert.equal((lastInit?.headers as Record<string, string>)?.['Content-Type'], undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('POST: method=POST, 序列化 body, 有 Content-Type', async () => {
|
||||||
|
await http.post('/api/v1/matches', { a: 1 })
|
||||||
|
assert.equal(lastInit?.method, 'POST')
|
||||||
|
assert.equal(lastInit?.body, JSON.stringify({ a: 1 }))
|
||||||
|
assert.equal((lastInit?.headers as Record<string, string>)?.['Content-Type'], 'application/json')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('POST 空 body: 不设 Content-Type', async () => {
|
||||||
|
await http.post('/api/v1/matches', undefined)
|
||||||
|
assert.equal(lastInit?.method, 'POST')
|
||||||
|
assert.equal(lastInit?.body, undefined)
|
||||||
|
assert.equal((lastInit?.headers as Record<string, string>)?.['Content-Type'], undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('PUT: method=PUT, 有 body 与 Content-Type', async () => {
|
||||||
|
await http.put('/api/v1/x', { b: 2 })
|
||||||
|
assert.equal(lastInit?.method, 'PUT')
|
||||||
|
assert.equal(lastInit?.body, JSON.stringify({ b: 2 }))
|
||||||
|
assert.equal((lastInit?.headers as Record<string, string>)?.['Content-Type'], 'application/json')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('DELETE: method=DELETE, 无 body, 无 Content-Type', async () => {
|
||||||
|
await http.delete('/api/v1/x/1')
|
||||||
|
assert.equal(lastInit?.method, 'DELETE')
|
||||||
|
assert.equal(lastInit?.body, undefined)
|
||||||
|
assert.equal((lastInit?.headers as Record<string, string>)?.['Content-Type'], undefined)
|
||||||
|
})
|
||||||
@@ -17,6 +17,14 @@ export const UNAUTHORIZED_EVENT = 'profeto:unauthorized'
|
|||||||
const API_BASE = '/api/v1'
|
const API_BASE = '/api/v1'
|
||||||
const DEFAULT_TIMEOUT = 30_000
|
const DEFAULT_TIMEOUT = 30_000
|
||||||
|
|
||||||
|
/** 所有 API 路径统一走 /api/v1,避免浏览器直接请求 /matches 被 nginx 当 SPA 回退 */
|
||||||
|
function build_url(path: string): string {
|
||||||
|
if (path.startsWith('http')) return path
|
||||||
|
if (path.startsWith(API_BASE)) return path
|
||||||
|
if (path.startsWith('/')) return `${API_BASE}${path}`
|
||||||
|
return `${API_BASE}/${path}`
|
||||||
|
}
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
message: string,
|
message: string,
|
||||||
@@ -37,7 +45,7 @@ interface RequestOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||||
const url = path.startsWith('http') ? path : path.startsWith('/') ? path : `${API_BASE}${path}`
|
const url = build_url(path)
|
||||||
const { timeoutMs = DEFAULT_TIMEOUT, skipAuthHandling, signal } = options
|
const { timeoutMs = DEFAULT_TIMEOUT, skipAuthHandling, signal } = options
|
||||||
|
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
@@ -49,10 +57,11 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, {
|
const method = (options.method ?? 'GET').toUpperCase()
|
||||||
signal: controller.signal,
|
const body = options.body
|
||||||
headers: { 'Content-Type': 'application/json' },
|
// 仅当有 body 时设置 Content-Type,避免 GET/DELETE 等无 body 请求被误标
|
||||||
})
|
const headers: Record<string, string> = body ? { 'Content-Type': 'application/json' } : {}
|
||||||
|
const res = await fetch(url, { signal: controller.signal, method, body, headers })
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const rawText = await res.text()
|
const rawText = await res.text()
|
||||||
|
|||||||
@@ -10,9 +10,11 @@
|
|||||||
* matches/components/MatchDetailSection.tsx — 赛程行 + 展开详情
|
* matches/components/MatchDetailSection.tsx — 赛程行 + 展开详情
|
||||||
* 本文件只负责状态装配与版面组织,不含数据获取与展示细节。
|
* 本文件只负责状态装配与版面组织,不含数据获取与展示细节。
|
||||||
*/
|
*/
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
import { fetchMatchDetail, fetchMatchContext } from '../admin/dal'
|
import { fetchMatchDetail, fetchMatchContext } from '../admin/dal'
|
||||||
import type { MatchDetailOut, MatchContextOut } from '../admin/types'
|
import type { MatchDetailOut, MatchContextOut } from '../admin/types'
|
||||||
|
import BackTop from '../components/BackTop'
|
||||||
import { useMatchesList } from './matches/hooks/useMatchesList'
|
import { useMatchesList } from './matches/hooks/useMatchesList'
|
||||||
import { useMatchPredict } from './matches/hooks/useMatchPredict'
|
import { useMatchPredict } from './matches/hooks/useMatchPredict'
|
||||||
import { useLeagues } from './matches/hooks/useLeagues'
|
import { useLeagues } from './matches/hooks/useLeagues'
|
||||||
@@ -38,13 +40,8 @@ export default function Matches() {
|
|||||||
loadMore,
|
loadMore,
|
||||||
} = useMatchesList({ onError: setError })
|
} = useMatchesList({ onError: setError })
|
||||||
|
|
||||||
const {
|
const { predictingId, prediction, predictionFor, predict, closePredict } =
|
||||||
predictingId,
|
useMatchPredict({ onError: setError })
|
||||||
prediction,
|
|
||||||
predictionFor,
|
|
||||||
predict,
|
|
||||||
closePredict,
|
|
||||||
} = useMatchPredict({ onError: setError })
|
|
||||||
|
|
||||||
// ── 详情展开(懒加载,只读,不触发 LLM) ──
|
// ── 详情展开(懒加载,只读,不触发 LLM) ──
|
||||||
const [expandedId, setExpandedId] = useState<number | null>(null)
|
const [expandedId, setExpandedId] = useState<number | null>(null)
|
||||||
@@ -52,16 +49,8 @@ export default function Matches() {
|
|||||||
const [contextMap, setContextMap] = useState<Record<number, MatchContextOut>>({})
|
const [contextMap, setContextMap] = useState<Record<number, MatchContextOut>>({})
|
||||||
const [detailLoading, setDetailLoading] = useState<number | null>(null)
|
const [detailLoading, setDetailLoading] = useState<number | null>(null)
|
||||||
|
|
||||||
// 监听滚动,超过 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' })
|
|
||||||
|
|
||||||
const leagueName = leagues.find(l => l.code === league)?.name ?? league
|
const leagueName = leagues.find(l => l.code === league)?.name ?? league
|
||||||
|
// 删除本地 showBackTop/scrollToTop,统一使用共享 BackTop 组件
|
||||||
|
|
||||||
/** 未开赛默认仅展示未来 3 天;其余状态展示全部。showAllUpcoming=true 时展开全部。 */
|
/** 未开赛默认仅展示未来 3 天;其余状态展示全部。showAllUpcoming=true 时展开全部。 */
|
||||||
const isScheduledView = status === 'scheduled'
|
const isScheduledView = status === 'scheduled'
|
||||||
@@ -90,20 +79,46 @@ export default function Matches() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 联赛 tab 溢出检测:可向右滚动时右缘显示渐隐提示
|
||||||
|
const leagueNavRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [canScrollRight, setCanScrollRight] = useState(false)
|
||||||
|
useEffect(() => {
|
||||||
|
const el = leagueNavRef.current
|
||||||
|
if (!el) return
|
||||||
|
const update = () => setCanScrollRight(el.scrollWidth - el.scrollLeft - el.clientWidth > 8)
|
||||||
|
update()
|
||||||
|
el.addEventListener('scroll', update, { passive: true })
|
||||||
|
window.addEventListener('resize', update)
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener('scroll', update)
|
||||||
|
window.removeEventListener('resize', update)
|
||||||
|
}
|
||||||
|
}, [leagues])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{/* ── 联赛版面切换 ── */}
|
{/* ── 联赛版面切换 ── */}
|
||||||
<nav className="flex items-center gap-6 overflow-x-auto border-b border-ink-900" aria-label="联赛">
|
<div className="relative">
|
||||||
{leagues.map(l => (
|
<nav
|
||||||
<button
|
ref={leagueNavRef}
|
||||||
key={l.code}
|
className="flex items-center gap-6 overflow-x-auto border-b border-ink-900"
|
||||||
onClick={() => setLeague(l.code)}
|
aria-label="联赛"
|
||||||
className={`relative tab ${league === l.code ? 'tab-on' : ''} font-serif`}
|
>
|
||||||
>
|
{leagues.map(l => (
|
||||||
{l.name}
|
<button
|
||||||
</button>
|
key={l.code}
|
||||||
))}
|
onClick={() => setLeague(l.code)}
|
||||||
</nav>
|
aria-current={league === l.code ? 'true' : undefined}
|
||||||
|
className={`relative tab ${league === l.code ? 'tab-on' : ''} font-serif`}
|
||||||
|
>
|
||||||
|
{l.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
{canScrollRight && (
|
||||||
|
<div aria-hidden="true" className="pointer-events-none absolute inset-y-0 right-0 w-10 bg-gradient-to-l from-paper-50 to-transparent" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ── 第二行:状态 / 模式 / 日期 / 计数 / 刷新(小屏 flex-wrap) ── */}
|
{/* ── 第二行:状态 / 模式 / 日期 / 计数 / 刷新(小屏 flex-wrap) ── */}
|
||||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-ink-500 sm:gap-x-5">
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-ink-500 sm:gap-x-5">
|
||||||
@@ -120,7 +135,7 @@ export default function Matches() {
|
|||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span className="ml-auto inline-flex items-center gap-3">
|
<span className="ml-auto inline-flex items-center gap-3 border-l border-ink-200 pl-4">
|
||||||
<span className="tabular-nums">
|
<span className="tabular-nums">
|
||||||
{isScheduledView && !showAllUpcoming && hasHiddenUpcoming
|
{isScheduledView && !showAllUpcoming && hasHiddenUpcoming
|
||||||
? `未来3天 ${visibleMatches.length} / 共 ${matches.length} 场`
|
? `未来3天 ${visibleMatches.length} / 共 ${matches.length} 场`
|
||||||
@@ -203,9 +218,9 @@ export default function Matches() {
|
|||||||
<>
|
<>
|
||||||
<p className="empty-state-title">本版暂无赛程</p>
|
<p className="empty-state-title">本版暂无赛程</p>
|
||||||
<p className="empty-state-sub">请先通过「数据采集」导入 {leagueName} 的比赛数据</p>
|
<p className="empty-state-sub">请先通过「数据采集」导入 {leagueName} 的比赛数据</p>
|
||||||
<a href="/admin/collection" className="empty-state-action">
|
<Link to="/admin/collection" className="empty-state-action">
|
||||||
前往数据采集 <span aria-hidden="true">→</span>
|
前往数据采集 <span aria-hidden="true">→</span>
|
||||||
</a>
|
</Link>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -265,18 +280,8 @@ export default function Matches() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* 回到顶部按钮 */}
|
{/* ── 回到顶部(共享组件,方角纸片风) ── */}
|
||||||
<button
|
<BackTop />
|
||||||
onClick={scrollToTop}
|
|
||||||
className={`fixed bottom-6 right-6 z-40 flex h-10 w-10 items-center justify-center rounded-full border border-ink-200 bg-paper-50 text-ink-600 shadow-lg transition-all duration-300 hover:border-ink-400 hover:text-ink-900 ${
|
|
||||||
showBackTop ? 'translate-y-0 opacity-100' : 'translate-y-4 opacity-0 pointer-events-none'
|
|
||||||
}`}
|
|
||||||
aria-label="回到顶部"
|
|
||||||
>
|
|
||||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import BackTop from '../components/BackTop'
|
||||||
import { fetchStandings } from '../admin/dal'
|
import { fetchStandings } from '../admin/dal'
|
||||||
import type { StandingsLeague, StandingRow } from '../admin/dal'
|
import type { StandingsLeague, StandingRow } from '../admin/dal'
|
||||||
import { useLeagues } from './matches/hooks/useLeagues'
|
import { useLeagues } from './matches/hooks/useLeagues'
|
||||||
@@ -13,24 +14,24 @@ import { Spinner } from '../admin/components'
|
|||||||
|
|
||||||
const ZONE_META: Record<string, { label: string; cls: string }> = {
|
const ZONE_META: Record<string, { label: string; cls: string }> = {
|
||||||
// 欧战资格
|
// 欧战资格
|
||||||
'Champions League': { label: '欧冠区', cls: 'bg-emerald-100 text-emerald-700' },
|
'Champions League': { label: '欧冠区', cls: 'bg-ok-100 text-ok-700' },
|
||||||
'Champions League Qualification': { label: '欧冠资格', cls: 'bg-emerald-100 text-emerald-700' },
|
'Champions League Qualification': { label: '欧冠资格', cls: 'bg-ok-100 text-ok-700' },
|
||||||
'Europa League': { label: '欧联区', cls: 'bg-amber-100 text-amber-700' },
|
'Europa League': { label: '欧联区', cls: 'bg-warn-100 text-warn-700' },
|
||||||
'Conference League': { label: '欧协杯', cls: 'bg-sky-100 text-sky-700' },
|
'Conference League': { label: '欧协杯', cls: 'bg-sky-100 text-sky-700' },
|
||||||
'Conference League Qualification': { 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': { label: '欧协杯', cls: 'bg-sky-100 text-sky-700' },
|
||||||
'Europa Conference League Qualification': { 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' },
|
'Championship': { label: '升级区', cls: 'bg-ok-100 text-ok-700' },
|
||||||
'Promotion': { label: '升级区', cls: 'bg-emerald-100 text-emerald-700' },
|
'Promotion': { label: '升级区', cls: 'bg-ok-100 text-ok-700' },
|
||||||
'Promotion Group': { label: '升级组', cls: 'bg-emerald-100 text-emerald-700' },
|
'Promotion Group': { label: '升级组', cls: 'bg-ok-100 text-ok-700' },
|
||||||
// 降级
|
// 降级
|
||||||
'Relegation': { label: '降级区', cls: 'bg-rose-100 text-rose-700' },
|
'Relegation': { label: '降级区', cls: 'bg-bad-100 text-bad-700' },
|
||||||
'Relegation Playoffs': { label: '降级附加赛', cls: 'bg-orange-100 text-orange-700' },
|
'Relegation Playoffs': { label: '降级附加赛', cls: 'bg-orange-100 text-orange-700' },
|
||||||
'Relegation Group': { label: '降级组', cls: 'bg-rose-100 text-rose-700' },
|
'Relegation Group': { label: '降级组', cls: 'bg-bad-100 text-bad-700' },
|
||||||
// 附加赛
|
// 附加赛
|
||||||
'Playoffs': { label: '附加赛', cls: 'bg-amber-100 text-amber-700' },
|
'Playoffs': { label: '附加赛', cls: 'bg-warn-100 text-warn-700' },
|
||||||
'Championship Playoffs': { label: '升级附加赛', cls: 'bg-amber-100 text-amber-700' },
|
'Championship Playoffs': { label: '升级附加赛', cls: 'bg-warn-100 text-warn-700' },
|
||||||
'Qualification Playoffs': { label: '资格附加赛', cls: 'bg-sky-100 text-sky-700' },
|
'Qualification Playoffs': { label: '资格附加赛', cls: 'bg-sky-100 text-sky-700' },
|
||||||
'Qualification': { label: '资格赛', cls: 'bg-sky-100 text-sky-700' },
|
'Qualification': { label: '资格赛', cls: 'bg-sky-100 text-sky-700' },
|
||||||
}
|
}
|
||||||
@@ -44,7 +45,7 @@ function zoneBadge(zone?: string | null) {
|
|||||||
/** 近期走势串(W/D/L) → 彩色圆点 */
|
/** 近期走势串(W/D/L) → 彩色圆点 */
|
||||||
function FormDots({ form }: { form?: string | null }) {
|
function FormDots({ form }: { form?: string | null }) {
|
||||||
if (!form) return <span className="text-2xs text-ink-400">—</span>
|
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' }
|
const colorMap: Record<string, string> = { W: 'bg-ok-500', D: 'bg-ink-300', L: 'bg-bad-500' }
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex gap-0.5">
|
<span className="inline-flex gap-0.5">
|
||||||
{form.slice(0, 5).split('').map((c, i) => (
|
{form.slice(0, 5).split('').map((c, i) => (
|
||||||
@@ -62,17 +63,6 @@ export default function StandingsPage() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [switching, setSwitching] = useState(false) // 切换联赛中
|
const [switching, setSwitching] = useState(false) // 切换联赛中
|
||||||
const [error, setError] = useState<string | null>(null)
|
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) => {
|
const load = useCallback(async (code?: string) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -134,7 +124,7 @@ export default function StandingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="border border-rose-300 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
<div className="border border-bad-300 bg-bad-50 px-4 py-3 text-sm text-bad-700">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -202,7 +192,7 @@ export default function StandingsPage() {
|
|||||||
<td className="text-center py-2 text-ink-500">{r.drawn}</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.lost}</td>
|
||||||
<td className="text-center py-2 text-ink-500">{r.goals_for}/{r.goals_against}</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'}`}>
|
<td className={`text-center py-2 ${r.goal_diff > 0 ? 'text-ok-600' : r.goal_diff < 0 ? 'text-bad-600' : 'text-ink-500'}`}>
|
||||||
{r.goal_diff > 0 ? `+${r.goal_diff}` : r.goal_diff}
|
{r.goal_diff > 0 ? `+${r.goal_diff}` : r.goal_diff}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-center py-2 font-bold text-ink-900">{r.points}</td>
|
<td className="text-center py-2 font-bold text-ink-900">{r.points}</td>
|
||||||
@@ -221,18 +211,8 @@ export default function StandingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 回到顶部按钮 */}
|
{/* 回到顶部(共享组件,方角纸片风) */}
|
||||||
<button
|
<BackTop />
|
||||||
onClick={scrollToTop}
|
|
||||||
className={`fixed bottom-6 right-6 z-40 flex h-10 w-10 items-center justify-center rounded-full border border-ink-200 bg-paper-50 text-ink-600 shadow-lg transition-all duration-300 hover:border-ink-400 hover:text-ink-900 ${
|
|
||||||
showBackTop ? 'translate-y-0 opacity-100' : 'translate-y-4 opacity-0 pointer-events-none'
|
|
||||||
}`}
|
|
||||||
aria-label="回到顶部"
|
|
||||||
>
|
|
||||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
* P3-1: 从 MatchPredictPanel.PredictionPanel 拆出,渲染逻辑原样搬迁。
|
* P3-1: 从 MatchPredictPanel.PredictionPanel 拆出,渲染逻辑原样搬迁。
|
||||||
*/
|
*/
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import type { AgentReport, Prediction } from '../../types'
|
import type { AgentReport, Prediction } from '../types'
|
||||||
import { AGENT_LABELS, CN_NUM } from '../../types'
|
import { AGENT_LABELS, CN_NUM } from '../types'
|
||||||
|
|
||||||
const STATUS_BADGE: Record<string, { label: string; cls: string }> = {
|
const STATUS_BADGE: Record<string, { label: string; cls: string }> = {
|
||||||
ok: { label: '正常', cls: 'text-ink-500' },
|
ok: { label: '正常', cls: 'text-ink-500' },
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* D3: 从 Matches.tsx 拆出,渲染逻辑原样搬迁。对外只导出 PredictModal;
|
* D3: 从 Matches.tsx 拆出,渲染逻辑原样搬迁。对外只导出 PredictModal;
|
||||||
* PredictionPanel 复用 Prediction 的 embedded 模式由弹窗内渲染。
|
* PredictionPanel 复用 Prediction 的 embedded 模式由弹窗内渲染。
|
||||||
*/
|
*/
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import TeamSideTag from '../../../components/TeamSideTag'
|
import TeamSideTag from '../../../components/TeamSideTag'
|
||||||
import type { Match, Prediction } from '../types'
|
import type { Match, Prediction } from '../types'
|
||||||
import { AGENT_LABELS } from '../types'
|
import { AGENT_LABELS } from '../types'
|
||||||
@@ -27,7 +27,7 @@ function Spinner({ className = '' }: { className?: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 胜平负一行文字:选中的红字加方块标记,未选中的退灰 */
|
/** 胜平负一行文字:选中的红字加方块标记,未选中的退灰 */
|
||||||
.**
|
/**
|
||||||
* P3-1:PredictionPanel 不再自绘,改为组合三个子组件:
|
* P3-1:PredictionPanel 不再自绘,改为组合三个子组件:
|
||||||
* OutcomePanel(比分/胜平负/成本) / AgentsPanel(专家意见) / ReasoningPanel(终裁/降级)。
|
* OutcomePanel(比分/胜平负/成本) / AgentsPanel(专家意见) / ReasoningPanel(终裁/降级)。
|
||||||
* 渲染输出与拆分前完全一致(仅降级警示 + 报头 + 元信息仍在此处)。
|
* 渲染输出与拆分前完全一致(仅降级警示 + 报头 + 元信息仍在此处)。
|
||||||
@@ -82,7 +82,7 @@ function PredictionPanel({
|
|||||||
{!degraded && <OutcomePanel prediction={prediction} match={match} />}
|
{!degraded && <OutcomePanel prediction={prediction} match={match} />}
|
||||||
|
|
||||||
<p className="text-center text-2xs text-ink-500">
|
<p className="text-center text-2xs text-ink-500">
|
||||||
`多专家模式 · ${okReports.length}/${reports.length} 路有效`
|
{`多专家模式 · ${okReports.length}/${reports.length} 路有效`}
|
||||||
{prediction.prompt_version && ` · prompt ${prediction.prompt_version}`}
|
{prediction.prompt_version && ` · prompt ${prediction.prompt_version}`}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -161,7 +161,8 @@ function PredictProgress() {
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<p className="mt-6 text-center text-2xs text-ink-400">
|
<p className="mt-6 text-center text-2xs text-ink-400">
|
||||||
五路专家并行分析后终裁,约需 30-90 秒;多专家调用消耗较多 token,请按需使用。关闭窗口即取消
|
五路专家并行分析后终裁,约需 30-90 秒;多专家调用消耗较多 token,请按需使用。
|
||||||
|
关闭窗口将停止进度显示,后台任务可能仍在执行并消耗额度。
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-center text-2xs text-ink-300">
|
<p className="mt-1 text-center text-2xs text-ink-300">
|
||||||
提示:每分钟限 10 次预测,耗尽后需等待下一分钟。
|
提示:每分钟限 10 次预测,耗尽后需等待下一分钟。
|
||||||
@@ -185,17 +186,52 @@ export function PredictModal({
|
|||||||
const homeName = match.home_team_zh || match.home_team
|
const homeName = match.home_team_zh || match.home_team
|
||||||
const awayName = match.away_team_zh || match.away_team
|
const awayName = match.away_team_zh || match.away_team
|
||||||
|
|
||||||
|
// ── 无障碍与滚动锁定 ──
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null)
|
||||||
|
const previouslyFocused = useRef<HTMLElement | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
previouslyFocused.current = document.activeElement as HTMLElement | null
|
||||||
|
// 初始聚焦弹窗容器,键盘用户可直接 Tab 进入内部控件
|
||||||
|
panelRef.current?.focus()
|
||||||
|
|
||||||
const h = (e: KeyboardEvent) => {
|
const h = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') onClose()
|
if (e.key === 'Escape') {
|
||||||
|
onClose()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key === 'Tab') {
|
||||||
|
// 简易焦点陷阱:Tab 循环限制在弹窗内,不会跑到遮罩背后的页面
|
||||||
|
const focusables = panelRef.current?.querySelectorAll<HTMLElement>(
|
||||||
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||||
|
)
|
||||||
|
if (!focusables || focusables.length === 0) return
|
||||||
|
const first = focusables[0]
|
||||||
|
const last = focusables[focusables.length - 1]
|
||||||
|
if (e.shiftKey && document.activeElement === first) {
|
||||||
|
e.preventDefault()
|
||||||
|
last.focus()
|
||||||
|
} else if (!e.shiftKey && document.activeElement === last) {
|
||||||
|
e.preventDefault()
|
||||||
|
first.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener('keydown', h)
|
document.addEventListener('keydown', h)
|
||||||
return () => document.removeEventListener('keydown', h)
|
// 锁定背景滚动:弹窗内滚到底继续滚时,不再带动底层页面
|
||||||
|
const prevOverflow = document.body.style.overflow
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', h)
|
||||||
|
document.body.style.overflow = prevOverflow
|
||||||
|
// 关闭后把焦点还给触发元素
|
||||||
|
previouslyFocused.current?.focus()
|
||||||
|
}
|
||||||
}, [onClose])
|
}, [onClose])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-ink-900/50 p-4 sm:items-center"
|
className="modal-overlay-enter fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-ink-900/50 p-4 sm:items-center"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={`预测 ${homeName} 对 ${awayName}`}
|
aria-label={`预测 ${homeName} 对 ${awayName}`}
|
||||||
@@ -203,7 +239,11 @@ export function PredictModal({
|
|||||||
if (e.target === e.currentTarget) onClose()
|
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
|
||||||
|
ref={panelRef}
|
||||||
|
tabIndex={-1}
|
||||||
|
className="modal-panel-enter relative flex max-h-[92vh] w-full max-w-2xl flex-col overflow-hidden bg-paper-50 outline-none"
|
||||||
|
>
|
||||||
{/* 弹窗报头 */}
|
{/* 弹窗报头 */}
|
||||||
<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">
|
<div className="flex flex-shrink-0 items-center justify-between border-b border-ink-900 bg-paper-100 px-4 py-2.5 sm:px-5">
|
||||||
<h3 className="flex flex-wrap items-center gap-1.5 font-serif text-sm font-bold text-ink-900">
|
<h3 className="flex flex-wrap items-center gap-1.5 font-serif text-sm font-bold text-ink-900">
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
*
|
*
|
||||||
* P3-1: 从 MatchPredictPanel.PredictionPanel 拆出,渲染逻辑原样搬迁。
|
* P3-1: 从 MatchPredictPanel.PredictionPanel 拆出,渲染逻辑原样搬迁。
|
||||||
*/
|
*/
|
||||||
import TeamSideTag from '../../../../components/TeamSideTag'
|
import TeamSideTag from '../../../components/TeamSideTag'
|
||||||
import type { Match, Prediction } from '../../types'
|
import type { Match, Prediction } from '../types'
|
||||||
import { OUTCOME_LABEL } from '../../types'
|
import { OUTCOME_LABEL } from '../types'
|
||||||
|
|
||||||
/** 置信度细线:0~1 数值的低调可视化 */
|
/** 置信度细线:0~1 数值的低调可视化 */
|
||||||
function Meter({ value }: { value: number }) {
|
function Meter({ value }: { value: number }) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*
|
*
|
||||||
* P3-1: 从 MatchPredictPanel.PredictionPanel 拆出,渲染逻辑原样搬迁。
|
* P3-1: 从 MatchPredictPanel.PredictionPanel 拆出,渲染逻辑原样搬迁。
|
||||||
*/
|
*/
|
||||||
import type { Prediction } from '../../types'
|
import type { Prediction } from '../types'
|
||||||
|
|
||||||
export function ReasoningPanel({ prediction }: { prediction: Prediction }) {
|
export function ReasoningPanel({ prediction }: { prediction: Prediction }) {
|
||||||
const degraded = prediction.status === 'degraded' || prediction.status === 'failed'
|
const degraded = prediction.status === 'degraded' || prediction.status === 'failed'
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { useRef, useState } from 'react'
|
|||||||
import { http } from '../../../lib/http'
|
import { http } from '../../../lib/http'
|
||||||
import type { Match, Prediction } from '../types'
|
import type { Match, Prediction } from '../types'
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise<void>(r => setTimeout(r, ms))
|
||||||
|
|
||||||
/** 把后端/网络错误翻译成用户可读文案 */
|
/** 把后端/网络错误翻译成用户可读文案 */
|
||||||
function readablePredictError(e: unknown): string {
|
function readablePredictError(e: unknown): string {
|
||||||
if (e instanceof Error) {
|
if (e instanceof Error) {
|
||||||
@@ -59,17 +61,43 @@ export function useMatchPredict({ onError }: UseMatchPredictOptions) {
|
|||||||
onError(null)
|
onError(null)
|
||||||
setPrediction(null)
|
setPrediction(null)
|
||||||
setPredictionFor(m)
|
setPredictionFor(m)
|
||||||
// LLM 多专家预测耗时可达数分钟,给足超时(与 nginx 代理 300s 对齐)
|
|
||||||
|
// P1-async: 预测改为异步,POST 立即返回 job_id,轮询结果避免网关超时(Cloudflare 100s → 524)
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
predictAbort.current = controller
|
predictAbort.current = controller
|
||||||
const timer = setTimeout(() => controller.abort(), 300_000)
|
const overallTimer = setTimeout(() => controller.abort(), 300_000)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await http.post<Prediction>('/predict', { match_id: m.id, mode: 'multi' }, {
|
// 1) 发起预测,拿到 job_id
|
||||||
timeoutMs: 300_000,
|
const started = await http.post<{ job_id: string; poll_url: string }>(
|
||||||
signal: controller.signal,
|
'/predict',
|
||||||
})
|
{ match_id: m.id, mode: 'multi' },
|
||||||
|
{ timeoutMs: 10_000, signal: controller.signal },
|
||||||
|
)
|
||||||
if (seq !== predictSeq.current) return
|
if (seq !== predictSeq.current) return
|
||||||
setPrediction(data)
|
|
||||||
|
// 2) 轮询直到终态(success/failed)或整体超时
|
||||||
|
const deadline = Date.now() + 300_000
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (controller.signal.aborted) throw new DOMException('aborted', 'AbortError')
|
||||||
|
await sleep(3000)
|
||||||
|
const job = await http.get<{ status: string; result?: Prediction; error?: string }>(
|
||||||
|
`/predict/jobs/${started.job_id}`,
|
||||||
|
{ timeoutMs: 5000, signal: controller.signal },
|
||||||
|
)
|
||||||
|
if (seq !== predictSeq.current) return
|
||||||
|
if (job.status === 'success') {
|
||||||
|
setPrediction(job.result ?? null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (job.status === 'failed') {
|
||||||
|
onError(job.error || '预测失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// status === 'running' → 继续轮询
|
||||||
|
}
|
||||||
|
// 整体超时
|
||||||
|
onError('预测超时(5 分钟),请稍后重试')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (seq !== predictSeq.current) return
|
if (seq !== predictSeq.current) return
|
||||||
onError(
|
onError(
|
||||||
@@ -78,7 +106,7 @@ export function useMatchPredict({ onError }: UseMatchPredictOptions) {
|
|||||||
: readablePredictError(e),
|
: readablePredictError(e),
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timer)
|
clearTimeout(overallTimer)
|
||||||
if (seq === predictSeq.current) setPredictingId(null)
|
if (seq === predictSeq.current) setPredictingId(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,12 +24,39 @@ export default {
|
|||||||
800: '#282420',
|
800: '#282420',
|
||||||
900: '#17140F',
|
900: '#17140F',
|
||||||
},
|
},
|
||||||
// 印报红:全站唯一强调色,克制使用
|
// 印报红:全站唯一强调色,克制使用
|
||||||
press: {
|
press: {
|
||||||
DEFAULT: '#9E1B1B',
|
DEFAULT: '#9E1B1B',
|
||||||
dark: '#7C1414',
|
dark: '#7C1414',
|
||||||
wash: '#F7E9E4',
|
wash: '#F7E9E4',
|
||||||
},
|
},
|
||||||
|
// ── 语义状态色:成功/警告/负面 ──
|
||||||
|
// 设计约束:纸底 #FDFCF8 上文字级(600/700)对比度 ≥ 4.5:1(WCAG AA),
|
||||||
|
// 点/条级(500) ≥ 3:1;色相降饱和以贴近墨色印刷感,不使用 tailwind 原色。
|
||||||
|
ok: {
|
||||||
|
50: '#EAF3ED',
|
||||||
|
100: '#D5E8DC',
|
||||||
|
300: '#A3C9B1',
|
||||||
|
500: '#43925F',
|
||||||
|
600: '#2E7A4C',
|
||||||
|
700: '#1F6B45',
|
||||||
|
},
|
||||||
|
warn: {
|
||||||
|
50: '#FBF3E4',
|
||||||
|
100: '#F5E5C8',
|
||||||
|
300: '#E2C48E',
|
||||||
|
500: '#D97706',
|
||||||
|
600: '#A15C0B',
|
||||||
|
700: '#8F5109',
|
||||||
|
},
|
||||||
|
bad: {
|
||||||
|
50: '#FAEDED',
|
||||||
|
100: '#F6E3E3',
|
||||||
|
300: '#E4AFAF',
|
||||||
|
500: '#C24A4A',
|
||||||
|
600: '#A83B3B',
|
||||||
|
700: '#8F3030',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
// 毛体草书(国内 CDN)+ 粗楷体回退
|
// 毛体草书(国内 CDN)+ 粗楷体回退
|
||||||
|
|||||||
@@ -16,5 +16,6 @@
|
|||||||
"noUnusedParameters": false,
|
"noUnusedParameters": false,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/*.test.ts", "src/**/*.spec.ts", "node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-2
@@ -14,6 +14,35 @@ from src.core.config import settings
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def _fail_stale_ingest_jobs() -> None:
|
||||||
|
"""P1-E: 启动时将上次遗留的 pending/running ingest_jobs 标 failed。
|
||||||
|
|
||||||
|
进程异常退出(重启/OOM)会导致 ingest_jobs 残留为 pending/running,
|
||||||
|
这些任务实际已不在执行,启动时一次性标 failed 避免永久"执行中"。
|
||||||
|
尽力而为:失败只记 warning,不阻断启动。
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import update
|
||||||
|
|
||||||
|
from src.db.base import AsyncSessionLocal
|
||||||
|
from src.db.models import IngestJob
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
stmt = (
|
||||||
|
update(IngestJob)
|
||||||
|
.where(IngestJob.status.in_(["pending", "running"]))
|
||||||
|
.values(status="failed", error="进程重启:任务被终止", finished_at=datetime.now(timezone.utc))
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
await session.commit()
|
||||||
|
if result.rowcount:
|
||||||
|
logger.info("P1-E: 已将 %d 条残留 pending/running ingest_jobs 标 failed", result.rowcount)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("P1-E: 清理残留 ingest_jobs 失败,不影响启动", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||||
from src.db.base import init_db
|
from src.db.base import init_db
|
||||||
@@ -30,6 +59,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|||||||
await migrate_plaintext_sensitive_settings() # 明文敏感配置 → 加密(幂等)
|
await migrate_plaintext_sensitive_settings() # 明文敏感配置 → 加密(幂等)
|
||||||
await ensure_admin_password_hashed() # .env 明文密码 → scrypt 哈希(幂等)
|
await ensure_admin_password_hashed() # .env 明文密码 → scrypt 哈希(幂等)
|
||||||
await assert_security_on_startup() # 启动安全校验(生产拒绝/开发警告)
|
await assert_security_on_startup() # 启动安全校验(生产拒绝/开发警告)
|
||||||
|
await _fail_stale_ingest_jobs() # P1-E: 上次遗留的 pending/running 标 failed
|
||||||
|
|
||||||
# D7(工程债): 进程内限流(_RateLimiter)与 KeyRing 均为单进程状态;
|
# D7(工程债): 进程内限流(_RateLimiter)与 KeyRing 均为单进程状态;
|
||||||
# 多 worker 部署时各进程独立计数,限流阈值会按 worker 数放大、KeyRing 不共享。
|
# 多 worker 部署时各进程独立计数,限流阈值会按 worker 数放大、KeyRing 不共享。
|
||||||
@@ -94,8 +124,8 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
from src.core.log_buffer import setup_memory_logging
|
from src.core.log_buffer import setup_logging
|
||||||
setup_memory_logging(settings.LOG_LEVEL)
|
setup_logging(settings.LOG_LEVEL, settings.LOG_FILE)
|
||||||
|
|
||||||
# 生产环境不暴露 OpenAPI 文档(避免向访客泄露接口结构)
|
# 生产环境不暴露 OpenAPI 文档(避免向访客泄露接口结构)
|
||||||
openapi_url = "/openapi.json" if settings.APP_ENV != "production" else None
|
openapi_url = "/openapi.json" if settings.APP_ENV != "production" else None
|
||||||
|
|||||||
@@ -33,6 +33,19 @@ _background_tasks: set[asyncio.Task] = set()
|
|||||||
VALID_TASKS = {"events", "standings", "stats", "all"}
|
VALID_TASKS = {"events", "standings", "stats", "all"}
|
||||||
|
|
||||||
|
|
||||||
|
def _accumulate_ingest_result(merged: dict, code: str, r: dict) -> None:
|
||||||
|
"""P1-A: 累加单联赛采集结果。联赛级计数读 r["leagues"][code],顶层读 total_*。"""
|
||||||
|
merged["total_inserted"] += r.get("total_inserted", 0)
|
||||||
|
merged["total_updated"] += r.get("total_updated", 0)
|
||||||
|
merged["errors"].extend(r.get("errors", []))
|
||||||
|
# 联赛级计数必须来自 leagues[code],而非顶层 r.get("inserted")
|
||||||
|
league_r = r.get("leagues", {}).get(code, {})
|
||||||
|
acc = merged["leagues"].setdefault(code, {"inserted": 0, "updated": 0, "errors": []})
|
||||||
|
acc["inserted"] += league_r.get("inserted", 0)
|
||||||
|
acc["updated"] += league_r.get("updated", 0)
|
||||||
|
acc["errors"].extend(r.get("errors", []))
|
||||||
|
|
||||||
|
|
||||||
def _spawn(coro) -> None:
|
def _spawn(coro) -> None:
|
||||||
"""启动后台采集任务;异常已在任务内记录到系统日志。"""
|
"""启动后台采集任务;异常已在任务内记录到系统日志。"""
|
||||||
task = asyncio.create_task(coro)
|
task = asyncio.create_task(coro)
|
||||||
@@ -105,13 +118,7 @@ async def _run_bzzoiro(job_id: str, task: str, leagues: list[str], req: IngestBz
|
|||||||
session, leagues=[code],
|
session, leagues=[code],
|
||||||
date_from=req.date_from, date_to=req.date_to, status=st,
|
date_from=req.date_from, date_to=req.date_to, status=st,
|
||||||
)
|
)
|
||||||
merged["total_inserted"] += r.get("total_inserted", 0)
|
_accumulate_ingest_result(merged, code, r)
|
||||||
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(
|
logger.info(
|
||||||
"bzzoiro 比赛采集完成: 新增 %d, 更新 %d, 联赛 %d 个, 状态 %s",
|
"bzzoiro 比赛采集完成: 新增 %d, 更新 %d, 联赛 %d 个, 状态 %s",
|
||||||
merged["total_inserted"], merged["total_updated"], len(merged["leagues"]), statuses,
|
merged["total_inserted"], merged["total_updated"], len(merged["leagues"]), statuses,
|
||||||
|
|||||||
+63
-36
@@ -14,6 +14,20 @@ from src.db.models import League, Match, MatchStats, Prediction, Standing, Team
|
|||||||
router = APIRouter(prefix="/api/v1", tags=["data"])
|
router = APIRouter(prefix="/api/v1", tags=["data"])
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_cursor(cursor: str) -> tuple[datetime, int]:
|
||||||
|
"""P1-B: 解析游标。非法格式 → HTTPException(400, code=INVALID_CURSOR)。"""
|
||||||
|
try:
|
||||||
|
last_date_str, last_id_str = cursor.split("|", 1)
|
||||||
|
last_date = datetime.fromisoformat(last_date_str)
|
||||||
|
last_id = int(last_id_str)
|
||||||
|
return last_date, last_id
|
||||||
|
except (ValueError, AttributeError) as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail={"code": "INVALID_CURSOR", "message": f"非法游标格式: {cursor}(应为 date_iso|id)"},
|
||||||
|
) from e
|
||||||
|
|
||||||
|
|
||||||
def _stats_dict(stats) -> dict | None:
|
def _stats_dict(stats) -> dict | None:
|
||||||
"""把 MatchStats ORM 对象序列化为前端可读的扁平 dict。"""
|
"""把 MatchStats ORM 对象序列化为前端可读的扁平 dict。"""
|
||||||
if stats is None:
|
if stats is None:
|
||||||
@@ -65,26 +79,21 @@ async def list_matches(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if cursor:
|
if cursor:
|
||||||
try:
|
# P1-B: 解析非法 → 400 + code=INVALID_CURSOR,而非静默忽略
|
||||||
# 用 | 分隔,避免 isoformat 含 _ 时解析失败
|
last_date, last_id = _parse_cursor(cursor)
|
||||||
last_date_str, last_id_str = cursor.split("|", 1)
|
# 游标方向必须与排序方向一致:
|
||||||
last_date = datetime.fromisoformat(last_date_str)
|
# - scheduled(ASC):取「更大」的未开赛场次
|
||||||
last_id = int(last_id_str)
|
# - 其它(DESC):取「更小」的已赛场次
|
||||||
# 游标方向必须与排序方向一致:
|
if status == "scheduled":
|
||||||
# - scheduled(ASC):取「更大」的未开赛场次
|
q = q.where(
|
||||||
# - 其它(DESC):取「更小」的已赛场次
|
(Match.match_date > last_date) |
|
||||||
if status == "scheduled":
|
((Match.match_date == last_date) & (Match.id > last_id))
|
||||||
q = q.where(
|
)
|
||||||
(Match.match_date > last_date) |
|
else:
|
||||||
((Match.match_date == last_date) & (Match.id > last_id))
|
q = q.where(
|
||||||
)
|
(Match.match_date < last_date) |
|
||||||
else:
|
((Match.match_date == last_date) & (Match.id < last_id))
|
||||||
q = q.where(
|
)
|
||||||
(Match.match_date < last_date) |
|
|
||||||
((Match.match_date == last_date) & (Match.id < last_id))
|
|
||||||
)
|
|
||||||
except (ValueError, AttributeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
if league:
|
if league:
|
||||||
stmt = select(League.id).where(League.code == league)
|
stmt = select(League.id).where(League.code == league)
|
||||||
@@ -160,15 +169,28 @@ async def get_match(match_id: int, db: AsyncSession = Depends(get_db_read)):
|
|||||||
m = (await db.execute(stmt)).scalar_one_or_none()
|
m = (await db.execute(stmt)).scalar_one_or_none()
|
||||||
if m is None:
|
if m is None:
|
||||||
raise HTTPException(404, "match not found")
|
raise HTTPException(404, "match not found")
|
||||||
# 最近预测(倒序,最多 5 条)——复用 PredictionOut 结构,只读,不触发 LLM
|
# P1-C: 公开预测仅 run_type=live 且 status=success(屏蔽回测/失败预测)
|
||||||
preds = (
|
preds = (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
select(Prediction)
|
select(Prediction)
|
||||||
.where(Prediction.match_id == match_id)
|
.where(Prediction.match_id == match_id)
|
||||||
|
.where(Prediction.run_type == "live")
|
||||||
|
.where(Prediction.status == "success")
|
||||||
.order_by(Prediction.created_at.desc())
|
.order_by(Prediction.created_at.desc())
|
||||||
.limit(5)
|
.limit(5)
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
|
# P1-C: 公开接口的预测不含 reasoning/agent_outputs(避免泄露内部推理细节)
|
||||||
|
recent_predictions = [
|
||||||
|
{
|
||||||
|
"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,
|
||||||
|
"pred_1x2": p.pred_1x2, "subjective_confidence": p.subjective_confidence,
|
||||||
|
"created_at": p.created_at.isoformat() if p.created_at else None,
|
||||||
|
}
|
||||||
|
for p in preds
|
||||||
|
]
|
||||||
return MatchOut(
|
return MatchOut(
|
||||||
id=m.id,
|
id=m.id,
|
||||||
league_code=m.league.code if m.league else None,
|
league_code=m.league.code if m.league else None,
|
||||||
@@ -185,20 +207,7 @@ async def get_match(match_id: int, db: AsyncSession = Depends(get_db_read)):
|
|||||||
home_xg=m.stats.home_xg if m.stats else None,
|
home_xg=m.stats.home_xg if m.stats else None,
|
||||||
away_xg=m.stats.away_xg if m.stats else None,
|
away_xg=m.stats.away_xg if m.stats else None,
|
||||||
stats=_stats_dict(m.stats) if m.stats else None,
|
stats=_stats_dict(m.stats) if m.stats else None,
|
||||||
recent_predictions=[
|
recent_predictions=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
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -286,7 +295,9 @@ async def list_standings(
|
|||||||
):
|
):
|
||||||
"""联赛积分榜(只读)。按联赛分组,每张榜按 position 排序。
|
"""联赛积分榜(只读)。按联赛分组,每张榜按 position 排序。
|
||||||
|
|
||||||
season 为空时返回每个联赛最新采集到的赛季榜单(适合前端"查看最新积分榜")。
|
P0-02: standings 为追加快照,公开接口取每队 available_at 最新快照
|
||||||
|
(league_id, season, team_id 上按 available_at 取最新)。
|
||||||
|
season 为空时返回每个联赛最新采集到的赛季榜单。
|
||||||
"""
|
"""
|
||||||
# 取每个联赛最新赛季(当 season 为空时)
|
# 取每个联赛最新赛季(当 season 为空时)
|
||||||
latest_seasons: dict[int, str] = {}
|
latest_seasons: dict[int, str] = {}
|
||||||
@@ -299,9 +310,25 @@ async def list_standings(
|
|||||||
).all()
|
).all()
|
||||||
latest_seasons = {r.league_id: r.latest for r in rows}
|
latest_seasons = {r.league_id: r.latest for r in rows}
|
||||||
|
|
||||||
|
# P0-02: 子查询取每队最新 available_at 快照,再 JOIN 回主表拿完整行 + League
|
||||||
|
latest_per_team = (
|
||||||
|
select(
|
||||||
|
Standing.league_id, Standing.season, Standing.team_id,
|
||||||
|
func.max(Standing.available_at).label("max_available"),
|
||||||
|
)
|
||||||
|
.group_by(Standing.league_id, Standing.season, Standing.team_id)
|
||||||
|
.subquery("latest_per_team")
|
||||||
|
)
|
||||||
q = (
|
q = (
|
||||||
select(Standing, League)
|
select(Standing, League)
|
||||||
.join(League, League.id == Standing.league_id)
|
.join(League, League.id == Standing.league_id)
|
||||||
|
.join(
|
||||||
|
latest_per_team,
|
||||||
|
(Standing.league_id == latest_per_team.c.league_id)
|
||||||
|
& (Standing.season == latest_per_team.c.season)
|
||||||
|
& (Standing.team_id == latest_per_team.c.team_id)
|
||||||
|
& (Standing.available_at == latest_per_team.c.max_available),
|
||||||
|
)
|
||||||
.order_by(League.name.asc(), Standing.position.asc())
|
.order_by(League.name.asc(), Standing.position.asc())
|
||||||
)
|
)
|
||||||
if league:
|
if league:
|
||||||
|
|||||||
+71
-61
@@ -2,11 +2,15 @@
|
|||||||
|
|
||||||
安全改进:
|
安全改进:
|
||||||
- 限流: 每分钟 10 次 / IP(内存实现)
|
- 限流: 每分钟 10 次 / IP(内存实现)
|
||||||
|
- P1-D: 全局 LLM 并发限制(默认 4),防止过多并发 LLM 调用压垮服务
|
||||||
- DB 连接: 短 session 模式,LLM 调用期间不持有连接
|
- DB 连接: 短 session 模式,LLM 调用期间不持有连接
|
||||||
|
- P1-async: 预测改为异步(后台任务 + 轮询),避免网关超时(Cloudflare 100s)
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -22,19 +26,65 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
router = APIRouter(prefix="/api/v1", tags=["predict"])
|
router = APIRouter(prefix="/api/v1", tags=["predict"])
|
||||||
|
|
||||||
|
# P1-D: 全局 LLM 并发限制。与 orchestrator 内的 match 级 Semaphore(8) 并存,
|
||||||
|
# 此处在路由层限制单实例全 LLM 调用(所有模式汇总),默认 4。
|
||||||
|
_GLOBAL_LLM_SEMAPHORE = asyncio.Semaphore(4)
|
||||||
|
|
||||||
@router.post("/predict", response_model=PredictOut, dependencies=[Depends(rate_limit_predict)])
|
# P1-async: 预测任务内存存储(job_id → 结果/异常)。单进程部署足够,无需入库。
|
||||||
|
_predict_jobs: dict[str, dict] = {}
|
||||||
|
|
||||||
|
|
||||||
|
async def _predict_with_concurrency(req: PredictRequest) -> PredictResult:
|
||||||
|
"""P1-D: 在全局 LLM 并发限制下执行预测。"""
|
||||||
|
async with _GLOBAL_LLM_SEMAPHORE:
|
||||||
|
return await predict_match(
|
||||||
|
req.match_id,
|
||||||
|
model=req.model,
|
||||||
|
prompt_version=req.prompt_version,
|
||||||
|
mode=req.mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_predict_async(job_id: str, req: PredictRequest) -> None:
|
||||||
|
"""P1-async: 后台执行预测,结果写入 _predict_jobs。"""
|
||||||
|
try:
|
||||||
|
result = await _predict_with_concurrency(req)
|
||||||
|
_predict_jobs[job_id] = {
|
||||||
|
"status": "success",
|
||||||
|
"result": {
|
||||||
|
"prediction_id": result.prediction_id,
|
||||||
|
"provider": result.provider,
|
||||||
|
"model": result.model,
|
||||||
|
"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,
|
||||||
|
"status": result.status,
|
||||||
|
"agent_outputs": result.agent_outputs,
|
||||||
|
"agent_weights": result.agent_weights,
|
||||||
|
"context": result.context,
|
||||||
|
"latency_ms": result.latency_ms,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("predict job %s failed", job_id)
|
||||||
|
_predict_jobs[job_id] = {"status": "failed", "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/predict", dependencies=[Depends(rate_limit_predict)])
|
||||||
async def predict(req: PredictRequest, request: Request):
|
async def predict(req: PredictRequest, request: Request):
|
||||||
"""对一场比赛调 LLM 预测。mode=multi(默认,5专家+终裁)、single 或 baseline。
|
"""对一场比赛调 LLM 预测(异步)。mode=multi(默认,5专家+终裁)、single 或 baseline。
|
||||||
|
|
||||||
公开接口,仅做限流保护(不要求登录)。
|
公开接口,仅做限流保护(不要求登录)。
|
||||||
|
P1-async: 立即返回 job_id,预测在后台执行,前端轮询 GET /predict/jobs/{job_id}。
|
||||||
DB 连接优化:
|
避免多专家预测耗时 60-180s 触发网关超时(Cloudflare 100s → HTTP 524)。
|
||||||
1. 短 read session 检查比赛存在性/状态
|
|
||||||
2. 释放连接后调用 LLM(可能几十秒)
|
|
||||||
3. 短 write session 保存 Prediction
|
|
||||||
"""
|
"""
|
||||||
# 1. 短 read session: 检查比赛(连接立即释放)
|
# 1. 短 read session: 检查比赛存在性/状态
|
||||||
async with short_read() as session:
|
async with short_read() as session:
|
||||||
m = await session.get(Match, req.match_id)
|
m = await session.get(Match, req.match_id)
|
||||||
if m is None:
|
if m is None:
|
||||||
@@ -42,61 +92,21 @@ async def predict(req: PredictRequest, request: Request):
|
|||||||
if m.match_status == "finished":
|
if m.match_status == "finished":
|
||||||
raise HTTPException(400, "该比赛已完赛,不再支持预测")
|
raise HTTPException(400, "该比赛已完赛,不再支持预测")
|
||||||
|
|
||||||
# 2. 预测调用(不持有任何 DB 连接)
|
# 2. P1-async: 启动后台任务,立即返回 job_id
|
||||||
try:
|
job_id = str(uuid.uuid4())
|
||||||
result = await predict_match(
|
_predict_jobs[job_id] = {"status": "running"}
|
||||||
req.match_id,
|
asyncio.create_task(_run_predict_async(job_id, req))
|
||||||
model=req.model,
|
logger.info("predict job started: %s match=%s mode=%s", job_id, req.match_id, req.mode)
|
||||||
prompt_version=req.prompt_version,
|
return {"job_id": job_id, "status": "running", "poll_url": f"/api/v1/predict/jobs/{job_id}"}
|
||||||
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:
|
|
||||||
logger.error("predict LLM error: %s", e)
|
|
||||||
raise HTTPException(502, "LLM 预测失败,请查看服务器日志")
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception("predict unexpected error")
|
|
||||||
raise HTTPException(500, "预测失败,请查看服务器日志")
|
|
||||||
|
|
||||||
# D2: 三种模式统一返回 PredictResult —— 字段映射单一化,无 dict 分支。
|
|
||||||
# P3-2:baseline 已在服务层(predict_baseline)落库并回填真实 prediction_id,
|
|
||||||
# 路由层不再需要特殊的 _persist_baseline,与 single/multi 路径统一。
|
|
||||||
prediction_id = result.prediction_id
|
|
||||||
|
|
||||||
# 3. 结果映射(无 DB 访问)
|
@router.get("/predict/jobs/{job_id}")
|
||||||
logger.info(
|
async def get_predict_job(job_id: str):
|
||||||
"预测完成 match=%s mode=%s pred=%s:%s (%s)",
|
"""P1-async: 轮询预测任务状态。"""
|
||||||
req.match_id, req.mode,
|
job = _predict_jobs.get(job_id)
|
||||||
result.pred_home_goals, result.pred_away_goals, result.pred_1x2,
|
if job is None:
|
||||||
)
|
raise HTTPException(404, f"预测任务不存在: {job_id}")
|
||||||
|
return job
|
||||||
return PredictOut(
|
|
||||||
prediction_id=prediction_id,
|
|
||||||
provider=result.provider,
|
|
||||||
model=result.model,
|
|
||||||
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,
|
|
||||||
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], dependencies=[Depends(require_admin)])
|
@router.get("/predictions", response_model=list[PredictionOut], dependencies=[Depends(require_admin)])
|
||||||
|
|||||||
+3
-17
@@ -7,13 +7,6 @@ from typing import Any
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
class LeagueOut(BaseModel):
|
|
||||||
id: int
|
|
||||||
code: str
|
|
||||||
name: str
|
|
||||||
country: str | None
|
|
||||||
|
|
||||||
|
|
||||||
class MatchOut(BaseModel):
|
class MatchOut(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
league_code: str | None
|
league_code: str | None
|
||||||
@@ -31,8 +24,8 @@ class MatchOut(BaseModel):
|
|||||||
away_xg: float | None = None
|
away_xg: float | None = None
|
||||||
# 比赛详细统计(bzzoiro /events/{id}/stats/),无统计为 None
|
# 比赛详细统计(bzzoiro /events/{id}/stats/),无统计为 None
|
||||||
stats: dict | None = None
|
stats: dict | None = None
|
||||||
# 该场比赛的最近预测摘要(按时间倒序,最多 5 条;无预测为空)
|
# P1-C: 公开接口的预测不含 reasoning/agent_outputs;仅 live+success 路由已过滤
|
||||||
recent_predictions: list[PredictionOut] = []
|
recent_predictions: list[dict] = []
|
||||||
|
|
||||||
|
|
||||||
class MatchListOut(BaseModel):
|
class MatchListOut(BaseModel):
|
||||||
@@ -43,7 +36,7 @@ class MatchListOut(BaseModel):
|
|||||||
|
|
||||||
class PredictRequest(BaseModel):
|
class PredictRequest(BaseModel):
|
||||||
match_id: int
|
match_id: int
|
||||||
provider: str | None = None
|
# P1-D: 删除未接线的 provider 字段(符合"名不副实则删除");provider 由服务端配置决定。
|
||||||
model: str | None = None
|
model: str | None = None
|
||||||
prompt_version: str | None = None
|
prompt_version: str | None = None
|
||||||
mode: str = Field(
|
mode: str = Field(
|
||||||
@@ -130,13 +123,6 @@ class TeamAliasOut(BaseModel):
|
|||||||
original_alias: str
|
original_alias: str
|
||||||
|
|
||||||
|
|
||||||
class IngestResponse(BaseModel):
|
|
||||||
leagues: dict
|
|
||||||
total_inserted: int
|
|
||||||
total_updated: int
|
|
||||||
errors: list[str] = []
|
|
||||||
|
|
||||||
|
|
||||||
class IngestBzzoiroResponse(BaseModel):
|
class IngestBzzoiroResponse(BaseModel):
|
||||||
"""POST /api/v1/ingest/bzzoiro 响应:兼容原 message 字段,新增 job_id 供轮询。"""
|
"""POST /api/v1/ingest/bzzoiro 响应:兼容原 message 字段,新增 job_id 供轮询。"""
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ class Settings(BaseSettings):
|
|||||||
# --- app ---
|
# --- app ---
|
||||||
APP_ENV: str = "development"
|
APP_ENV: str = "development"
|
||||||
LOG_LEVEL: str = "INFO"
|
LOG_LEVEL: str = "INFO"
|
||||||
|
# 日志持久化:空(默认)只输出 stdout + Admin 内存日志页(重启清零)。
|
||||||
|
# 填文件路径(如 /app/logs/app.log)后额外写入滚动文件(单文件 10MB × 5 份),
|
||||||
|
# 进程/容器重启不丢。容器部署需配合 volume 挂载该目录,否则重建仍会丢。
|
||||||
|
LOG_FILE: str = ""
|
||||||
# P3-3:多 worker 时应用内限流与 KeyRing 各自独立计数(配额放大 N 倍)。
|
# P3-3:多 worker 时应用内限流与 KeyRing 各自独立计数(配额放大 N 倍)。
|
||||||
# 设为 True 时若以多 worker 启动 uvicorn 则拒绝启动,避免静默配额漂移。
|
# 设为 True 时若以多 worker 启动 uvicorn 则拒绝启动,避免静默配额漂移。
|
||||||
# 仅在你已前置 Nginx/网关做全局限流、确认不需要此守护时留空/False。
|
# 仅在你已前置 Nginx/网关做全局限流、确认不需要此守护时留空/False。
|
||||||
|
|||||||
+48
-8
@@ -65,14 +65,54 @@ def get_entries(
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def setup_memory_logging(level: str = "INFO") -> None:
|
def setup_logging(level: str = "INFO", log_file: str = "") -> None:
|
||||||
"""挂载内存 handler 到 root logger(幂等),并确保 root 级别不低于 INFO。"""
|
"""配置应用日志:stdout(容器收集) + 内存环形缓冲(Admin 日志页) + 可选滚动文件(持久化)。
|
||||||
|
|
||||||
|
幂等:重复调用不会重复挂 handler(文件 handler 按 abspath 判重,相对/绝对
|
||||||
|
路径指向同一文件视为同一个)。文件写入基础设施失败只记 warning,绝不
|
||||||
|
影响启动与业务 —— 与 _safe_write_ingest_failure 同级约束:可观测性
|
||||||
|
基础设施不许拖垮主流程。
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
|
|
||||||
root = logging.getLogger()
|
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:
|
if root.level == logging.NOTSET or root.level > logging.INFO:
|
||||||
root.setLevel(getattr(logging, level.upper(), logging.INFO))
|
root.setLevel(getattr(logging, level.upper(), logging.INFO))
|
||||||
|
|
||||||
|
if not any(isinstance(h, MemoryLogHandler) for h in root.handlers):
|
||||||
|
handler = MemoryLogHandler()
|
||||||
|
handler.setLevel(logging.INFO)
|
||||||
|
handler.addFilter(_SQLNoiseFilter())
|
||||||
|
root.addHandler(handler)
|
||||||
|
|
||||||
|
if not log_file:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 滚动上限:单文件 10MB × 当前+5 份 ≈ 60MB,足够回溯数周的关键事件,
|
||||||
|
# 又不会吃满磁盘。格式含时间/级别/logger 名,便于事后 grep 排查。
|
||||||
|
target = os.path.abspath(log_file)
|
||||||
|
if any(
|
||||||
|
isinstance(h, RotatingFileHandler) and getattr(h, "baseFilename", None) == target
|
||||||
|
for h in root.handlers
|
||||||
|
):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(target), exist_ok=True)
|
||||||
|
file_handler = RotatingFileHandler(
|
||||||
|
target,
|
||||||
|
maxBytes=10 * 1024 * 1024,
|
||||||
|
backupCount=5,
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
file_handler.setFormatter(
|
||||||
|
logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s")
|
||||||
|
)
|
||||||
|
file_handler.setLevel(logging.INFO)
|
||||||
|
file_handler.addFilter(_SQLNoiseFilter())
|
||||||
|
root.addHandler(file_handler)
|
||||||
|
logging.getLogger(__name__).info("文件日志已启用: %s", log_file)
|
||||||
|
except Exception:
|
||||||
|
logging.getLogger(__name__).warning(
|
||||||
|
"启用文件日志失败(%s),仅保留 stdout/内存日志", log_file, exc_info=True,
|
||||||
|
)
|
||||||
|
|||||||
+46
-20
@@ -104,17 +104,26 @@ async def get_runtime_value(key: str) -> str:
|
|||||||
"""读运行时配置:DB 覆盖值 → .env 默认值 → 空串。
|
"""读运行时配置:DB 覆盖值 → .env 默认值 → 空串。
|
||||||
|
|
||||||
敏感项入库时是密文,读出后自动解密;旧明文(迁移前)由 decrypt_value 透传。
|
敏感项入库时是密文,读出后自动解密;旧明文(迁移前)由 decrypt_value 透传。
|
||||||
|
P1-G:DB 故障回落 env;但解密失败在生产环境必须抛出(不得与 DB 异常共用 except)。
|
||||||
"""
|
"""
|
||||||
defn = SETTING_DEFS.get(key)
|
defn = SETTING_DEFS.get(key)
|
||||||
|
db_value = None
|
||||||
try:
|
try:
|
||||||
async with AsyncSessionLocal() as db:
|
async with AsyncSessionLocal() as db:
|
||||||
row = await db.get(AppSetting, key)
|
row = await db.get(AppSetting, key)
|
||||||
if row and row.value:
|
if row and row.value:
|
||||||
value = crypto.decrypt_value(row.value) if defn and defn.sensitive else row.value
|
db_value = row.value
|
||||||
if value:
|
|
||||||
return value
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("读取运行时配置 %s 失败,回落环境变量", key)
|
logger.warning("读取运行时配置 %s 失败(DB 故障),回落环境变量", key)
|
||||||
|
return getattr(settings, key, "") or ""
|
||||||
|
|
||||||
|
if db_value is None:
|
||||||
|
return getattr(settings, key, "") or ""
|
||||||
|
|
||||||
|
# 解密逻辑独立于 DB 异常处理(P1-G:解密失败生产环境必须抛出)
|
||||||
|
value = crypto.decrypt_value(db_value) if defn and defn.sensitive else db_value
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
return getattr(settings, key, "") or ""
|
return getattr(settings, key, "") or ""
|
||||||
|
|
||||||
|
|
||||||
@@ -133,8 +142,40 @@ async def set_runtime_value(key: str, value: str) -> None:
|
|||||||
logger.info("运行时配置 %s 已更新", key)
|
logger.info("运行时配置 %s 已更新", key)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_setting_origin(key: str) -> tuple[str, str]:
|
||||||
|
"""返回 (origin, 当前生效值)。origin ∈ db / env / none。
|
||||||
|
|
||||||
|
P1-G:DB 故障回落 env;解密失败在生产环境抛出。
|
||||||
|
"""
|
||||||
|
defn = SETTING_DEFS.get(key)
|
||||||
|
db_value = None
|
||||||
|
try:
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
row = await db.get(AppSetting, key)
|
||||||
|
if row and row.value:
|
||||||
|
db_value = row.value
|
||||||
|
except Exception:
|
||||||
|
logger.warning("读取运行时配置 %s 来源失败(DB 故障),按环境变量处理", key)
|
||||||
|
env_value = getattr(settings, key, "") or ""
|
||||||
|
return ("env", env_value) if env_value else ("none", "")
|
||||||
|
|
||||||
|
if db_value is None:
|
||||||
|
return ("none", "")
|
||||||
|
|
||||||
|
try:
|
||||||
|
value = crypto.decrypt_value(db_value) if defn and defn.sensitive else db_value
|
||||||
|
return "db", value
|
||||||
|
except ValueError as e:
|
||||||
|
# P1-G:解密失败(SECRET_KEY 不一致)在生产环境必须抛出,不得静默回落
|
||||||
|
if settings.APP_ENV == "production":
|
||||||
|
raise
|
||||||
|
logger.warning("解密 %s 失败(非生产环境回落): %s", key, e)
|
||||||
|
env_value = getattr(settings, key, "") or ""
|
||||||
|
return ("env", env_value) if env_value else ("none", "")
|
||||||
|
|
||||||
|
|
||||||
async def clear_runtime_value(key: str) -> None:
|
async def clear_runtime_value(key: str) -> None:
|
||||||
"""删除 DB 覆盖值,回落 .env(调用方需先校验 key 在白名单内)。"""
|
"""清除 DB 覆盖值,回落 .env(调用方需先校验 key 在白名单内)。"""
|
||||||
async with AsyncSessionLocal() as db:
|
async with AsyncSessionLocal() as db:
|
||||||
row = await db.get(AppSetting, key)
|
row = await db.get(AppSetting, key)
|
||||||
if row is not None:
|
if row is not None:
|
||||||
@@ -143,21 +184,6 @@ async def clear_runtime_value(key: str) -> None:
|
|||||||
logger.info("运行时配置 %s 已清除覆盖", key)
|
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:
|
async def migrate_plaintext_sensitive_settings() -> int:
|
||||||
"""一次性迁移:把库中仍是明文的敏感项加密(幂等,启动时执行)。
|
"""一次性迁移:把库中仍是明文的敏感项加密(幂等,启动时执行)。
|
||||||
|
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ class DataQualityScheduler:
|
|||||||
.select_from(Match)
|
.select_from(Match)
|
||||||
.outerjoin(MatchStats, Match.id == MatchStats.match_id)
|
.outerjoin(MatchStats, Match.id == MatchStats.match_id)
|
||||||
.where(Match.match_status == "finished")
|
.where(Match.match_status == "finished")
|
||||||
.where(MatchStats.id.is_(None))
|
.where(MatchStats.match_id.is_(None))
|
||||||
)
|
)
|
||||||
).scalar() or 0
|
).scalar() or 0
|
||||||
|
|
||||||
|
|||||||
@@ -30,10 +30,6 @@ _MIN_SECRET_KEY_LEN = 16
|
|||||||
_WEAK_DB_PATTERNS = ("football:football@", "admin:admin@", "password@", "123456@")
|
_WEAK_DB_PATTERNS = ("football:football@", "admin:admin@", "password@", "123456@")
|
||||||
|
|
||||||
|
|
||||||
class SecurityCheckError(Exception):
|
|
||||||
"""生产环境安全校验失败。"""
|
|
||||||
|
|
||||||
|
|
||||||
async def _auth_configured() -> bool:
|
async def _auth_configured() -> bool:
|
||||||
"""运行时鉴权是否已配置(含数据库密码哈希/.env 明文/API Key)。"""
|
"""运行时鉴权是否已配置(含数据库密码哈希/.env 明文/API Key)。"""
|
||||||
if await get_admin_password_hash():
|
if await get_admin_password_hash():
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ class BzzoiroSource:
|
|||||||
match_date=nm.date,
|
match_date=nm.date,
|
||||||
match_date_date=_to_date(nm.date),
|
match_date_date=_to_date(nm.date),
|
||||||
match_status=nm.match_status,
|
match_status=nm.match_status,
|
||||||
|
score_status=nm.score_status,
|
||||||
home_goals=nm.home_goals,
|
home_goals=nm.home_goals,
|
||||||
away_goals=nm.away_goals,
|
away_goals=nm.away_goals,
|
||||||
home_ht_goals=nm.home_ht_goals,
|
home_ht_goals=nm.home_ht_goals,
|
||||||
@@ -238,6 +239,16 @@ class BzzoiroSource:
|
|||||||
existing_match.away_goals = nm.away_goals
|
existing_match.away_goals = nm.away_goals
|
||||||
existing_match.home_ht_goals = nm.home_ht_goals
|
existing_match.home_ht_goals = nm.home_ht_goals
|
||||||
existing_match.away_ht_goals = nm.away_ht_goals
|
existing_match.away_ht_goals = nm.away_ht_goals
|
||||||
|
# 比分由缺变有 → 标记 known
|
||||||
|
existing_match.score_status = "known"
|
||||||
|
changed = True
|
||||||
|
elif (
|
||||||
|
nm.match_status == "finished"
|
||||||
|
and nm.home_goals is None
|
||||||
|
and existing_match.score_status == "unknown"
|
||||||
|
):
|
||||||
|
# 确认完赛仍缺分 → 标记 missing(不伪造 0:0)
|
||||||
|
existing_match.score_status = "missing"
|
||||||
changed = True
|
changed = True
|
||||||
if existing_match.match_stage is None and nm.match_stage:
|
if existing_match.match_stage is None and nm.match_stage:
|
||||||
existing_match.match_stage = nm.match_stage
|
existing_match.match_stage = nm.match_stage
|
||||||
|
|||||||
@@ -137,21 +137,12 @@ async def ingest_bzzoiro_standings(db, *, leagues: Iterable[str], season: str |
|
|||||||
retrieved_at=now,
|
retrieved_at=now,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 同一联赛同一赛季只保留最新快照:按 (league, season, team) upsert
|
# P0-02: 追加快照——每次采集 INSERT 新行(available_at=now),
|
||||||
stmt = select(Standing).where(
|
# ON CONFLICT (league, season, team, available_at) DO NOTHING。
|
||||||
Standing.league_id == league.id,
|
standing = Standing(
|
||||||
Standing.season == season_label,
|
league_id=league.id, season=season_label, team_id=team.id, available_at=now, **values
|
||||||
Standing.team_id == team.id,
|
|
||||||
)
|
)
|
||||||
standing = (await db.execute(stmt)).scalar_one_or_none()
|
db.add(standing)
|
||||||
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["upserted"] += 1
|
||||||
|
|
||||||
league_r["rows"] = len(rows)
|
league_r["rows"] = len(rows)
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ class NormalizedMatch:
|
|||||||
home_team: str
|
home_team: str
|
||||||
away_team: str
|
away_team: str
|
||||||
match_status: str = "finished"
|
match_status: str = "finished"
|
||||||
|
# P0-01:比分可信度。known=可靠比分;missing=完赛缺分;unknown=待定。
|
||||||
|
score_status: str = "unknown"
|
||||||
home_goals: int | None = None
|
home_goals: int | None = None
|
||||||
away_goals: int | None = None
|
away_goals: int | None = None
|
||||||
season_label: str = ""
|
season_label: str = ""
|
||||||
@@ -217,5 +219,11 @@ def normalize_bzzoiro(raw: dict, league_type: str) -> NormalizedMatch | None:
|
|||||||
m.away_red_cards = _to_int(raw.get("away_red_cards", raw.get("red_cards_away")))
|
m.away_red_cards = _to_int(raw.get("away_red_cards", raw.get("red_cards_away")))
|
||||||
|
|
||||||
if m.match_status == "finished" and m.home_goals is None:
|
if m.match_status == "finished" and m.home_goals is None:
|
||||||
m.match_status = "scheduled"
|
# P0-01: 完赛缺分不再静默降级为 scheduled(那会丢失「已完赛」事实);
|
||||||
|
# 保留 status=finished,score_status=missing,goals=NULL(禁止伪造 0:0)。
|
||||||
|
m.score_status = "missing"
|
||||||
|
elif m.home_goals is not None and m.away_goals is not None:
|
||||||
|
m.score_status = "known"
|
||||||
|
else:
|
||||||
|
m.score_status = "unknown"
|
||||||
return m
|
return m
|
||||||
|
|||||||
@@ -68,25 +68,6 @@ async def short_read():
|
|||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def short_write():
|
|
||||||
"""短生命周期 write session: 提交后立即释放。
|
|
||||||
|
|
||||||
用法:
|
|
||||||
async with short_write() as session:
|
|
||||||
session.add(pred)
|
|
||||||
await session.commit()
|
|
||||||
# session 已关闭,连接已释放
|
|
||||||
"""
|
|
||||||
async with AsyncSessionLocal() as session:
|
|
||||||
try:
|
|
||||||
yield session
|
|
||||||
await session.commit()
|
|
||||||
except Exception:
|
|
||||||
await session.rollback()
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
async def init_db() -> None:
|
async def init_db() -> None:
|
||||||
"""验证数据库连接(不建表)。
|
"""验证数据库连接(不建表)。
|
||||||
|
|
||||||
|
|||||||
+31
-12
@@ -19,6 +19,7 @@ from sqlalchemy import (
|
|||||||
String,
|
String,
|
||||||
Text,
|
Text,
|
||||||
UniqueConstraint,
|
UniqueConstraint,
|
||||||
|
column,
|
||||||
func,
|
func,
|
||||||
)
|
)
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
@@ -88,6 +89,9 @@ class Match(Base):
|
|||||||
index=True,
|
index=True,
|
||||||
)
|
)
|
||||||
match_status: Mapped[str] = mapped_column(String(20), default="scheduled")
|
match_status: Mapped[str] = mapped_column(String(20), default="scheduled")
|
||||||
|
# P0-01:比分可信度标记。known=有可靠比分;missing=完赛但缺分(保留 NULL 不伪造 0:0);
|
||||||
|
# unknown=待定(无比分且未确认完赛)。禁止把缺分写成 0:0。
|
||||||
|
score_status: Mapped[str] = mapped_column(String(20), server_default="unknown", nullable=False)
|
||||||
home_goals: Mapped[int | None] = mapped_column(Integer)
|
home_goals: Mapped[int | None] = mapped_column(Integer)
|
||||||
away_goals: Mapped[int | None] = mapped_column(Integer)
|
away_goals: Mapped[int | None] = mapped_column(Integer)
|
||||||
home_ht_goals: Mapped[int | None] = mapped_column(Integer)
|
home_ht_goals: Mapped[int | None] = mapped_column(Integer)
|
||||||
@@ -127,10 +131,19 @@ class Match(Base):
|
|||||||
"match_date_date",
|
"match_date_date",
|
||||||
unique=True,
|
unique=True,
|
||||||
),
|
),
|
||||||
# DB-5: 数据库级约束 — 已完赛比赛必须有比分
|
# P0-01:比分可信度约束(替代原 ck_matches_finished_has_score):
|
||||||
|
# - score_status=known → 必须有比分(非 NULL)
|
||||||
|
# - score_status=missing → 必须 NULL(完赛缺分,禁止伪造 0:0)
|
||||||
|
# - score_status=unknown → 必须 NULL
|
||||||
CheckConstraint(
|
CheckConstraint(
|
||||||
"match_status <> 'finished' OR (home_goals IS NOT NULL AND away_goals IS NOT NULL)",
|
"score_status IN ('known', 'missing', 'unknown')",
|
||||||
name="ck_matches_finished_has_score",
|
name="ck_matches_score_status_enum",
|
||||||
|
),
|
||||||
|
CheckConstraint(
|
||||||
|
"match_status <> 'finished'"
|
||||||
|
" OR (score_status = 'known' AND home_goals IS NOT NULL AND away_goals IS NOT NULL)"
|
||||||
|
" OR (score_status IN ('missing', 'unknown') AND home_goals IS NULL AND away_goals IS NULL)",
|
||||||
|
name="ck_matches_score_integrity",
|
||||||
),
|
),
|
||||||
CheckConstraint(
|
CheckConstraint(
|
||||||
"match_status IN ('finished', 'scheduled', 'in_play', 'paused', 'postponed', 'cancelled', 'suspended')",
|
"match_status IN ('finished', 'scheduled', 'in_play', 'paused', 'postponed', 'cancelled', 'suspended')",
|
||||||
@@ -192,8 +205,11 @@ class MatchStats(Base):
|
|||||||
class Standing(Base):
|
class Standing(Base):
|
||||||
"""联赛积分榜快照(bzzoiro /leagues/{id}/standings/)。
|
"""联赛积分榜快照(bzzoiro /leagues/{id}/standings/)。
|
||||||
|
|
||||||
同一联赛同一赛季只保留最新快照:重新采集时按 (league_id, season, team_id)
|
P0-02: 改为追加快照(append-only)。每次采集 INSERT 新行,available_at=now;
|
||||||
upsert。zone 来自 bzzoiro 分区(如 champions_league / europa_league / relegation)。
|
查询取 available_at<=cutoff 的每队最新快照(DISTINCT ON team_id ORDER available_at DESC)。
|
||||||
|
回测时可还原任意历史时刻的榜单,不再只是"最新快照、忽略 cutoff"。
|
||||||
|
同一 (league_id, season, team_id, available_at) 唯一,ON CONFLICT DO NOTHING。
|
||||||
|
zone 来自 bzzoiro 分区(如 champions_league / europa_league / relegation)。
|
||||||
"""
|
"""
|
||||||
__tablename__ = "standings"
|
__tablename__ = "standings"
|
||||||
|
|
||||||
@@ -216,13 +232,17 @@ class Standing(Base):
|
|||||||
zone: Mapped[str | None] = mapped_column(String(50)) # champions_league / relegation 等
|
zone: Mapped[str | None] = mapped_column(String(50)) # champions_league / relegation 等
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
|
||||||
retrieved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
retrieved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
||||||
|
# P0-02: 快照可用时间(采集时间),唯一键组成部分 + cutoff 过滤依据
|
||||||
|
available_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=_utcnow)
|
||||||
|
|
||||||
league: Mapped[League] = relationship()
|
league: Mapped[League] = relationship()
|
||||||
team: Mapped[Team] = relationship(lazy="selectin")
|
team: Mapped[Team] = relationship(lazy="selectin")
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
UniqueConstraint("league_id", "season", "team_id", name="uq_standings_league_season_team"),
|
# P0-02: (league, season, team, available_at) 唯一,支持追加快照 + ON CONFLICT DO NOTHING
|
||||||
|
UniqueConstraint("league_id", "season", "team_id", "available_at", name="uq_standings_league_season_team_available"),
|
||||||
Index("ix_standings_league_season_pos", "league_id", "season", "position"),
|
Index("ix_standings_league_season_pos", "league_id", "season", "position"),
|
||||||
|
Index("ix_standings_league_season_team_available", "league_id", "season", "team_id", "available_at"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -270,14 +290,13 @@ class Prediction(Base):
|
|||||||
match: Mapped[Match] = relationship(back_populates="predictions")
|
match: Mapped[Match] = relationship(back_populates="predictions")
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
# Fix: 唯一约束增加 mode + run_type,允许 live 与 backtest 共存
|
# P0-03: 幂等指纹——input_hash 非空时唯一(同指纹→返回已有行,不 UPDATE/INSERT);
|
||||||
# 防止回测覆盖未结算的实盘预测(后续 settle 会污染评估数据)
|
# 兼容旧数据 NULL input_hash(不强制回填)。
|
||||||
UniqueConstraint(
|
Index(
|
||||||
"match_id", "provider", "model", "mode", "run_type",
|
"ix_predictions_input_hash_unique", "input_hash", unique=True,
|
||||||
name="uq_predictions_match_provider_model_mode_run_type",
|
postgresql_where=column("input_hash").isnot(None),
|
||||||
),
|
),
|
||||||
Index("ix_predictions_match", "match_id"),
|
Index("ix_predictions_match", "match_id"),
|
||||||
Index("ix_predictions_provider_model", "provider", "model"),
|
|
||||||
# 数据截止时间过滤查询用(按 prediction_cutoff_at 取「赛前已生成」的预测)
|
# 数据截止时间过滤查询用(按 prediction_cutoff_at 取「赛前已生成」的预测)
|
||||||
Index("ix_predictions_cutoff_at", "prediction_cutoff_at"),
|
Index("ix_predictions_cutoff_at", "prediction_cutoff_at"),
|
||||||
# 数据库级约束:最后一道防线
|
# 数据库级约束:最后一道防线
|
||||||
|
|||||||
+15
-23
@@ -13,6 +13,7 @@ from datetime import datetime
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
|
|
||||||
from src.db.models import League, Match, Prediction, Team
|
from src.db.models import League, Match, Prediction, Team
|
||||||
|
|
||||||
@@ -153,12 +154,14 @@ class TeamRepository:
|
|||||||
logger.info("Team 别名命中: %s -> %s(已有 id=%s)", name, normalized, team.id)
|
logger.info("Team 别名命中: %s -> %s(已有 id=%s)", name, normalized, team.id)
|
||||||
return team
|
return team
|
||||||
|
|
||||||
# 3) 新建 Team(归一名)
|
# 3) 新建 Team(归一名)。P1-K: 用 PG UPSERT 防并发重复插入。
|
||||||
logger.info("创建新 Team: %s -> %s", name, normalized)
|
logger.info("创建新 Team: %s -> %s", name, normalized)
|
||||||
team = Team(name=normalized, name_zh=name_zh)
|
stmt = pg_insert(Team).values(name=normalized, name_zh=name_zh)
|
||||||
self._session.add(team)
|
stmt = stmt.on_conflict_do_nothing(index_elements=["name"])
|
||||||
|
await self._session.execute(stmt)
|
||||||
await self._session.flush()
|
await self._session.flush()
|
||||||
return team
|
# 无论是否本次插入,都拿到行(并发时可能已由对方创建)
|
||||||
|
return await self.get_by_name(normalized)
|
||||||
|
|
||||||
async def add_alias(self, alias: str, team_id: int) -> TeamAlias:
|
async def add_alias(self, alias: str, team_id: int) -> TeamAlias:
|
||||||
"""为已有 Team 添加别名。
|
"""为已有 Team 添加别名。
|
||||||
@@ -205,27 +208,16 @@ class LeagueRepository:
|
|||||||
return (await self._session.execute(stmt)).scalar_one_or_none()
|
return (await self._session.execute(stmt)).scalar_one_or_none()
|
||||||
|
|
||||||
async def get_or_create(self, code: str, name: str, country: str | None = None) -> League:
|
async def get_or_create(self, code: str, name: str, country: str | None = None) -> League:
|
||||||
|
"""按代码获取联赛,不存在则创建。P1-K: 创建用 PG UPSERT 防并发重复。"""
|
||||||
league = await self.get_by_code(code)
|
league = await self.get_by_code(code)
|
||||||
if league is None:
|
if league is not None:
|
||||||
league = League(code=code, name=name, country=country)
|
return league
|
||||||
self._session.add(league)
|
stmt = pg_insert(League).values(code=code, name=name, country=country)
|
||||||
await self._session.flush()
|
stmt = stmt.on_conflict_do_nothing(index_elements=["code"])
|
||||||
return league
|
await self._session.execute(stmt)
|
||||||
|
await self._session.flush()
|
||||||
|
return await self.get_by_code(code)
|
||||||
|
|
||||||
async def add(self, league: League) -> None:
|
async def add(self, league: League) -> None:
|
||||||
self._session.add(league)
|
self._session.add(league)
|
||||||
await self._session.flush()
|
await self._session.flush()
|
||||||
|
|
||||||
|
|
||||||
class PredictionRepository:
|
|
||||||
"""预测记录数据访问。"""
|
|
||||||
|
|
||||||
def __init__(self, session: AsyncSession) -> None:
|
|
||||||
self._session = session
|
|
||||||
|
|
||||||
async def get_by_id(self, prediction_id: int) -> Prediction | None:
|
|
||||||
return await self._session.get(Prediction, prediction_id)
|
|
||||||
|
|
||||||
async def add(self, prediction: Prediction) -> None:
|
|
||||||
self._session.add(prediction)
|
|
||||||
await self._session.flush()
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from src.core.config import settings
|
|||||||
from src.db.base import AsyncSessionLocal
|
from src.db.base import AsyncSessionLocal
|
||||||
from src.db.models import Match, Prediction
|
from src.db.models import Match, Prediction
|
||||||
from src.db.unit_of_work import get_uow
|
from src.db.unit_of_work import get_uow
|
||||||
from src.llm.predict import PredictResult, _upsert_prediction
|
from src.llm.predict import PredictResult, _insert_or_find_by_fingerprint
|
||||||
from src.llm.agents.base import AgentReport, AgentSpec, load_agent_prompt
|
from src.llm.agents.base import AgentReport, AgentSpec, load_agent_prompt
|
||||||
from src.llm.context_builder import (
|
from src.llm.context_builder import (
|
||||||
MatchHeader,
|
MatchHeader,
|
||||||
@@ -285,10 +285,13 @@ async def predict_match_multi(
|
|||||||
|
|
||||||
latency_ms = int((time.perf_counter() - start) * 1000)
|
latency_ms = int((time.perf_counter() - start) * 1000)
|
||||||
|
|
||||||
# 3.5 计算输入 hash(基于终裁报告)
|
# P0-03: 指纹输入——终裁报告 hash 作 context_hash,专家列表作 agent_ids
|
||||||
input_hash = hashlib.sha256(
|
reports_json = _reports_to_json(reports)
|
||||||
_reports_to_json(reports).encode("utf-8")
|
context_hash = hashlib.sha256(reports_json.encode("utf-8")).hexdigest()
|
||||||
).hexdigest()
|
agent_ids = sorted([r.agent for r in reports]) if reports else []
|
||||||
|
# 终裁模板 hash(规范:复用 prompt 版本 + 终裁 system prompt)
|
||||||
|
prompt_hash = hashlib.sha256(f"multi_{version}".encode("utf-8")).hexdigest()
|
||||||
|
system_prompt_hash = hashlib.sha256(AGGREGATOR_SYSTEM.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
# 4. 存库(使用 UnitOfWork)
|
# 4. 存库(使用 UnitOfWork)
|
||||||
async with get_uow() as session:
|
async with get_uow() as session:
|
||||||
@@ -315,15 +318,20 @@ async def predict_match_multi(
|
|||||||
pred_status = "degraded"
|
pred_status = "degraded"
|
||||||
model_name = aggregator_model
|
model_name = aggregator_model
|
||||||
|
|
||||||
pred = await _upsert_prediction(
|
pred = await _insert_or_find_by_fingerprint(
|
||||||
session,
|
session,
|
||||||
match_id=match_id,
|
|
||||||
provider_name=settings.LLM_PROVIDER,
|
|
||||||
model=model_name,
|
|
||||||
mode="multi",
|
|
||||||
run_type="backtest" if backtest else "live",
|
|
||||||
values={
|
values={
|
||||||
|
"match_id": match_id,
|
||||||
|
"provider": settings.LLM_PROVIDER,
|
||||||
|
"model": model_name,
|
||||||
|
"mode": "multi",
|
||||||
|
"run_type": "backtest" if backtest else "live",
|
||||||
"prompt_version": f"multi_{version}",
|
"prompt_version": f"multi_{version}",
|
||||||
|
"prompt_hash": prompt_hash,
|
||||||
|
"system_prompt_hash": system_prompt_hash,
|
||||||
|
"temperature": 0.2,
|
||||||
|
"context_hash": context_hash,
|
||||||
|
"agent_ids": agent_ids,
|
||||||
"prompt_tokens": sum(r.prompt_tokens or 0 for r in reports) + agg_prompt_tokens,
|
"prompt_tokens": sum(r.prompt_tokens or 0 for r in reports) + agg_prompt_tokens,
|
||||||
"completion_tokens": sum(r.completion_tokens or 0 for r in reports) + agg_completion_tokens,
|
"completion_tokens": sum(r.completion_tokens or 0 for r in reports) + agg_completion_tokens,
|
||||||
"latency_ms": latency_ms,
|
"latency_ms": latency_ms,
|
||||||
@@ -341,7 +349,6 @@ async def predict_match_multi(
|
|||||||
"match_kickoff_at": match_kickoff_at,
|
"match_kickoff_at": match_kickoff_at,
|
||||||
"prediction_cutoff_at": prediction_cutoff_at,
|
"prediction_cutoff_at": prediction_cutoff_at,
|
||||||
"prediction_created_at": now,
|
"prediction_created_at": now,
|
||||||
"input_hash": input_hash,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ async def _get_historical_matches(
|
|||||||
selectinload(Match.away_team),
|
selectinload(Match.away_team),
|
||||||
)
|
)
|
||||||
.where(Match.match_status == "finished")
|
.where(Match.match_status == "finished")
|
||||||
|
.where(Match.score_status == "known")
|
||||||
.where(Match.home_goals.is_not(None))
|
.where(Match.home_goals.is_not(None))
|
||||||
.where(Match.away_goals.is_not(None))
|
.where(Match.away_goals.is_not(None))
|
||||||
)
|
)
|
||||||
|
|||||||
+20
-12
@@ -5,14 +5,15 @@
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from sqlalchemy import case, func, select
|
from sqlalchemy import case, func, select
|
||||||
|
|
||||||
from src.db.base import AsyncSession, AsyncSessionLocal
|
from src.db.base import AsyncSession, AsyncSessionLocal
|
||||||
from src.db.models import Match
|
from src.db.models import Match
|
||||||
from src.llm.predict import PredictResult, _upsert_prediction
|
from src.llm.predict import PredictResult, _insert_or_find_by_fingerprint
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -97,8 +98,23 @@ async def predict_baseline(
|
|||||||
else:
|
else:
|
||||||
pred_1x2 = "X"
|
pred_1x2 = "X"
|
||||||
|
|
||||||
|
# P0-03: 基线指纹——基于主客场场均进球数据(context_hash) + 截止时间
|
||||||
|
context_hash = hashlib.sha256(
|
||||||
|
f"{home_avg:.4f}:{away_avg:.4f}:{before.isoformat() if before else 'none'}".encode("utf-8")
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
values = {
|
values = {
|
||||||
|
"match_id": match_id,
|
||||||
|
"provider": "baseline",
|
||||||
|
"model": "baseline",
|
||||||
|
"mode": "baseline",
|
||||||
|
"run_type": "live",
|
||||||
"prompt_version": "baseline_v1",
|
"prompt_version": "baseline_v1",
|
||||||
|
"prompt_hash": hashlib.sha256(b"baseline_v1").hexdigest(),
|
||||||
|
"system_prompt_hash": hashlib.sha256(b"baseline").hexdigest(),
|
||||||
|
"temperature": 0.0,
|
||||||
|
"context_hash": context_hash,
|
||||||
|
"agent_ids": [],
|
||||||
"prompt_tokens": 0,
|
"prompt_tokens": 0,
|
||||||
"completion_tokens": 0,
|
"completion_tokens": 0,
|
||||||
"latency_ms": 0,
|
"latency_ms": 0,
|
||||||
@@ -114,17 +130,9 @@ async def predict_baseline(
|
|||||||
"status": "success",
|
"status": "success",
|
||||||
}
|
}
|
||||||
|
|
||||||
# P3-2:服务层落库,回填真实 prediction_id(与 single/multi 统一)。
|
# P0-03:服务层幂等插入,回填真实 prediction_id(与 single/multi 统一)。
|
||||||
async with get_uow() as session:
|
async with get_uow() as session:
|
||||||
pred = await _upsert_prediction(
|
pred = await _insert_or_find_by_fingerprint(session, values=values)
|
||||||
session,
|
|
||||||
match_id=match_id,
|
|
||||||
provider_name="baseline",
|
|
||||||
model="baseline",
|
|
||||||
mode="baseline",
|
|
||||||
run_type="live",
|
|
||||||
values=values,
|
|
||||||
)
|
|
||||||
prediction_id = pred.id
|
prediction_id = pred.id
|
||||||
|
|
||||||
return PredictResult(
|
return PredictResult(
|
||||||
|
|||||||
+23
-10
@@ -7,6 +7,7 @@ from sqlalchemy import func, or_, select
|
|||||||
|
|
||||||
from src.db.models import Prediction, Match, League
|
from src.db.models import Prediction, Match, League
|
||||||
from src.db.unit_of_work import get_uow
|
from src.db.unit_of_work import get_uow
|
||||||
|
from src.llm.utils import actual_1x2
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -33,12 +34,8 @@ async def settle_prediction(prediction_id: int, home_goals: int, away_goals: int
|
|||||||
|
|
||||||
|
|
||||||
def _actual_1x2(home: int, away: int) -> str:
|
def _actual_1x2(home: int, away: int) -> str:
|
||||||
"""根据实际比分返胜平负。"""
|
"""根据实际比分返胜平负(委托 utils.actual_1x2 单一权威源)。"""
|
||||||
if home > away:
|
return actual_1x2(home, away)
|
||||||
return "1"
|
|
||||||
if home == away:
|
|
||||||
return "X"
|
|
||||||
return "2"
|
|
||||||
|
|
||||||
|
|
||||||
def _build_filters(
|
def _build_filters(
|
||||||
@@ -47,9 +44,17 @@ def _build_filters(
|
|||||||
prompt_version: str | None = None,
|
prompt_version: str | None = None,
|
||||||
mode: str | None = None,
|
mode: str | None = None,
|
||||||
league_code: str | None = None,
|
league_code: str | None = None,
|
||||||
|
run_type: str | None = "live",
|
||||||
|
season: str | None = None,
|
||||||
) -> list:
|
) -> list:
|
||||||
"""构建评估筛选条件(参数化列明,防拼接注入)。"""
|
"""构建评估筛选条件(参数化列明,防拼接注入)。
|
||||||
|
|
||||||
|
P1-I: 默认仅 run_type=live(排除回测污染),可显式改 "backtest"/None。
|
||||||
|
"""
|
||||||
filters = [Prediction.settled == True]
|
filters = [Prediction.settled == True]
|
||||||
|
# P1-I: 默认仅统计实盘预测(排除回测),除非显式指定
|
||||||
|
if run_type is not None:
|
||||||
|
filters.append(Prediction.run_type == run_type)
|
||||||
if provider:
|
if provider:
|
||||||
filters.append(Prediction.provider == provider)
|
filters.append(Prediction.provider == provider)
|
||||||
if model:
|
if model:
|
||||||
@@ -58,6 +63,10 @@ def _build_filters(
|
|||||||
filters.append(Prediction.prompt_version == prompt_version)
|
filters.append(Prediction.prompt_version == prompt_version)
|
||||||
if mode:
|
if mode:
|
||||||
filters.append(Prediction.mode == mode)
|
filters.append(Prediction.mode == mode)
|
||||||
|
# P1-I: 赛季过滤
|
||||||
|
if season:
|
||||||
|
season_subq = select(Match.id).where(Match.season == season).scalar_subquery()
|
||||||
|
filters.append(Prediction.match_id.in_(season_subq))
|
||||||
if league_code:
|
if league_code:
|
||||||
league_subq = select(League.id).where(League.code == league_code).scalar_subquery()
|
league_subq = select(League.id).where(League.code == league_code).scalar_subquery()
|
||||||
filters.append(Prediction.match_id.in_(
|
filters.append(Prediction.match_id.in_(
|
||||||
@@ -67,17 +76,21 @@ def _build_filters(
|
|||||||
|
|
||||||
|
|
||||||
async def get_eval_summary(
|
async def get_eval_summary(
|
||||||
limit: int = 1000,
|
limit: int | None = None,
|
||||||
*,
|
*,
|
||||||
provider: str | None = None,
|
provider: str | None = None,
|
||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
prompt_version: str | None = None,
|
prompt_version: str | None = None,
|
||||||
mode: str | None = None,
|
mode: str | None = None,
|
||||||
league_code: str | None = None,
|
league_code: str | None = None,
|
||||||
|
run_type: str | None = "live",
|
||||||
|
season: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""按 provider × 模型聚合评估。
|
"""按 provider × 模型聚合评估。
|
||||||
|
|
||||||
P3-4: 默认限制评估最近 1000 条已结算预测,避免全表加载导致内存压力。
|
P1-I: 默认 limit=None(返回全集,不冒充 1000 条为全集);调用方可显式 limit 采样。
|
||||||
|
默认仅 run_type=live(排除回测污染),可按需改 "backtest"。
|
||||||
|
可选 season 过滤。
|
||||||
|
|
||||||
只统计有效预测:
|
只统计有效预测:
|
||||||
- settled == True
|
- settled == True
|
||||||
@@ -85,7 +98,7 @@ async def get_eval_summary(
|
|||||||
- 预测比分字段齐全
|
- 预测比分字段齐全
|
||||||
degraded 或无比分的预测不计入准确率。
|
degraded 或无比分的预测不计入准确率。
|
||||||
"""
|
"""
|
||||||
filters = _build_filters(provider, model, prompt_version, mode, league_code)
|
filters = _build_filters(provider, model, prompt_version, mode, league_code, run_type=run_type, season=season)
|
||||||
|
|
||||||
async with get_uow() as session:
|
async with get_uow() as session:
|
||||||
total_settled = (await session.execute(
|
total_settled = (await session.execute(
|
||||||
|
|||||||
+80
-64
@@ -4,9 +4,9 @@
|
|||||||
|
|
||||||
| 模式 | 落库位置(服务层) | 路由层(routes/predict.py) |
|
| 模式 | 落库位置(服务层) | 路由层(routes/predict.py) |
|
||||||
|-----------|-------------------------------------------------------------|---------------------------|
|
|-----------|-------------------------------------------------------------|---------------------------|
|
||||||
| single | `_predict_single` → `_upsert_prediction` | 不读 DB,仅映射 result → PredictOut |
|
| single | `_predict_single` → `_insert_or_find_by_fingerprint` | 不读 DB,仅映射 result → PredictOut |
|
||||||
| multi | `orchestrator.predict_match_multi` → `_upsert_prediction` | 不读 DB,仅映射 result → PredictOut |
|
| multi | `orchestrator.predict_match_multi` → `_insert_or_find_by_fingerprint` | 不读 DB,仅映射 result → PredictOut |
|
||||||
| baseline | `predict_baseline` → `_upsert_prediction` | 不读 DB,仅映射 result → PredictOut |
|
| baseline | `predict_baseline` → `_insert_or_find_by_fingerprint` | 不读 DB,仅映射 result → PredictOut |
|
||||||
|
|
||||||
三种模式统一在服务层经 UnitOfWork 落库并回填真实 prediction_id;
|
三种模式统一在服务层经 UnitOfWork 落库并回填真实 prediction_id;
|
||||||
路由层永不写入 predictions,只读 result.prediction_id 做响应映射。
|
路由层永不写入 predictions,只读 result.prediction_id 做响应映射。
|
||||||
@@ -126,12 +126,14 @@ class _RedisCache(_CacheBackend):
|
|||||||
if not await self._ensure_conn():
|
if not await self._ensure_conn():
|
||||||
return self._memory_fallback.get(key)
|
return self._memory_fallback.get(key)
|
||||||
try:
|
try:
|
||||||
import pickle
|
import json
|
||||||
|
|
||||||
raw = await self._redis.get(key) # type: ignore[union-attr]
|
raw = await self._redis.get(key) # type: ignore[union-attr]
|
||||||
if raw is None:
|
if raw is None:
|
||||||
return None
|
return None
|
||||||
return pickle.loads(raw.encode("latin-1")) if isinstance(raw, str) else pickle.loads(raw)
|
# P1-H: JSON 序列化(替代 pickle,跨语言安全 + 可人工阅读)
|
||||||
|
data = json.loads(raw)
|
||||||
|
return PredictResult(**data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("predict cache: Redis GET 失败(%s),跳过缓存", e)
|
logger.warning("predict cache: Redis GET 失败(%s),跳过缓存", e)
|
||||||
return None
|
return None
|
||||||
@@ -141,10 +143,15 @@ class _RedisCache(_CacheBackend):
|
|||||||
await self._memory_fallback.set(key, result, ttl)
|
await self._memory_fallback.set(key, result, ttl)
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
import pickle
|
import json
|
||||||
|
from dataclasses import asdict
|
||||||
|
|
||||||
payload = pickle.dumps(result).decode("latin-1")
|
payload = asdict(result)
|
||||||
await self._redis.set(key, payload, ex=ttl) # type: ignore[union-attr]
|
# JSON 序列化;处理 datetime → ISO 字符串
|
||||||
|
for k, v in payload.items():
|
||||||
|
if hasattr(v, "isoformat"):
|
||||||
|
payload[k] = v.isoformat()
|
||||||
|
await self._redis.set(key, json.dumps(payload, default=str), ex=ttl) # type: ignore[union-attr]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("predict cache: Redis SET 失败(%s),降级内存写入", e)
|
logger.warning("predict cache: Redis SET 失败(%s),降级内存写入", e)
|
||||||
await self._memory_fallback.set(key, result, ttl)
|
await self._memory_fallback.set(key, result, ttl)
|
||||||
@@ -172,20 +179,6 @@ async def _set_cached(match_id: int, provider: str, model: str, version: str, tp
|
|||||||
await _cache_backend.set(key, result, _CACHE_TTL_SEC)
|
await _cache_backend.set(key, result, _CACHE_TTL_SEC)
|
||||||
|
|
||||||
|
|
||||||
def clear_prompt_cache() -> None:
|
|
||||||
"""清空 prompt 模板缓存(供开发/热更新时手动调用)。
|
|
||||||
|
|
||||||
lru_cache 的模板缓存是进程级的,改完 .md 需要重启进程才能生效;
|
|
||||||
提供显式清理入口,避免"改了模板却看不到变化"的困惑(见审查报告 P2-5)。
|
|
||||||
同时清空预测响应缓存(内存后端);Redis 后端因共享不清除。
|
|
||||||
"""
|
|
||||||
_load_prompt_template.cache_clear()
|
|
||||||
if isinstance(_cache_backend, _MemoryCache):
|
|
||||||
_cache_backend._store.clear()
|
|
||||||
logger.info("prompt 模板缓存 + 预测响应缓存(内存)已清空")
|
|
||||||
|
|
||||||
|
|
||||||
@functools.lru_cache(maxsize=8)
|
|
||||||
def _load_prompt_template(version: str = "v1") -> str:
|
def _load_prompt_template(version: str = "v1") -> str:
|
||||||
"""缓存 prompt 模板(进程生命周期内每个版本只读一次)。"""
|
"""缓存 prompt 模板(进程生命周期内每个版本只读一次)。"""
|
||||||
path = _PROMPT_DIR / f"match_prediction_{version}.md"
|
path = _PROMPT_DIR / f"match_prediction_{version}.md"
|
||||||
@@ -234,44 +227,60 @@ class PredictResult:
|
|||||||
raw: dict | None = None
|
raw: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
async def _upsert_prediction(
|
def _compute_fingerprint(values: dict) -> str:
|
||||||
session,
|
"""P0-03: 预测指纹(规范 JSON 的 SHA-256)。
|
||||||
*,
|
|
||||||
match_id: int,
|
|
||||||
provider_name: str,
|
|
||||||
model: str,
|
|
||||||
mode: str,
|
|
||||||
run_type: str,
|
|
||||||
values: dict,
|
|
||||||
) -> Prediction:
|
|
||||||
"""按 (match, provider, model, mode, run_type) 唯一约束写入预测。
|
|
||||||
|
|
||||||
已存在且未结算 → 覆盖更新(重新预测语义);已结算 → 拒绝(保护评估数据)。
|
捕获影响预测输出的全部因素:输入、提示、模型、采样、截止时间、专家。
|
||||||
run_type 区分 live/backtest,避免回测覆盖实盘预测。
|
同 fingerprint → 返回已有行(不 UPDATE/INSERT);不同 → INSERT 新行。
|
||||||
"""
|
"""
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
canonical = {
|
||||||
|
"match_id": values.get("match_id"),
|
||||||
|
"prediction_cutoff_at": _iso(values.get("prediction_cutoff_at")),
|
||||||
|
"prompt_version": values.get("prompt_version"),
|
||||||
|
"prompt_hash": values.get("prompt_hash"),
|
||||||
|
"system_prompt_hash": values.get("system_prompt_hash"),
|
||||||
|
"provider": values.get("provider"),
|
||||||
|
"model": values.get("model"),
|
||||||
|
"mode": values.get("mode"),
|
||||||
|
"run_type": values.get("run_type"),
|
||||||
|
"temperature": values.get("temperature"),
|
||||||
|
"context_hash": values.get("context_hash"),
|
||||||
|
"agent_ids": sorted(values.get("agent_ids") or []),
|
||||||
|
}
|
||||||
|
blob = _json.dumps(canonical, sort_keys=True, separators=(',', ':'))
|
||||||
|
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _iso(v) -> str | None:
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
if hasattr(v, "isoformat"):
|
||||||
|
return v.isoformat()
|
||||||
|
return str(v)
|
||||||
|
|
||||||
|
|
||||||
|
async def _insert_or_find_by_fingerprint(session, *, values: dict) -> Prediction:
|
||||||
|
"""P0-03: 幂等插入——同 input_hash 返回已有行(不 UPDATE);不同则 INSERT。
|
||||||
|
|
||||||
|
不再按 (match, provider, model, mode, run_type) 做 upsert,避免覆盖已有预测。
|
||||||
|
values 必须包含 fingerprint 所需全部字段(见 _compute_fingerprint)。
|
||||||
|
"""
|
||||||
|
fingerprint = _compute_fingerprint(values)
|
||||||
|
values["input_hash"] = fingerprint
|
||||||
|
|
||||||
existing = (
|
existing = (
|
||||||
await session.execute(
|
await session.execute(
|
||||||
select(Prediction).where(
|
select(Prediction).where(Prediction.input_hash == fingerprint)
|
||||||
Prediction.match_id == match_id,
|
|
||||||
Prediction.provider == provider_name,
|
|
||||||
Prediction.model == model,
|
|
||||||
Prediction.mode == mode,
|
|
||||||
Prediction.run_type == run_type,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
if existing is not None and existing.settled:
|
if existing is not None:
|
||||||
raise ValueError("该比赛已有已结算的预测,不能重新预测")
|
# 同指纹 → 直接返回,绝不覆盖 pred_* / reasoning / agent_outputs
|
||||||
|
return existing
|
||||||
|
|
||||||
pred = existing if existing is not None else Prediction(
|
pred = Prediction(**{k: v for k, v in values.items() if hasattr(Prediction, k)})
|
||||||
match_id=match_id, provider=provider_name, model=model,
|
session.add(pred)
|
||||||
)
|
|
||||||
pred.mode = mode
|
|
||||||
pred.run_type = run_type
|
|
||||||
for k, v in values.items():
|
|
||||||
setattr(pred, k, v)
|
|
||||||
if existing is None:
|
|
||||||
session.add(pred)
|
|
||||||
await session.flush() # 拿到自增 id;事务由 UnitOfWork 退出时提交
|
await session.flush() # 拿到自增 id;事务由 UnitOfWork 退出时提交
|
||||||
return pred
|
return pred
|
||||||
|
|
||||||
@@ -356,23 +365,26 @@ async def _predict_single(
|
|||||||
# 1. 拼上下文(backtest/cutoff 防泄漏)
|
# 1. 拼上下文(backtest/cutoff 防泄漏)
|
||||||
ctx = await build_context(match_id, backtest=backtest, cutoff_at=cutoff_at)
|
ctx = await build_context(match_id, backtest=backtest, cutoff_at=cutoff_at)
|
||||||
|
|
||||||
# 1.5 计算快照元数据(用于可复现性)
|
# 1.5 计算快照元数据(用于可复现性 + P0-03 指纹)
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
match_kickoff_at = ctx.match_dt
|
match_kickoff_at = ctx.match_dt
|
||||||
# 使用上下文实际计算的 cutoff(回测时可能为 match_dt-1天),而非开球时间
|
# 使用上下文实际计算的 cutoff(回测时可能为 match_dt-1天),而非开球时间
|
||||||
prediction_cutoff_at = ctx.cutoff if ctx.cutoff is not None else ctx.match_dt
|
prediction_cutoff_at = ctx.cutoff if ctx.cutoff is not None else ctx.match_dt
|
||||||
input_hash = hashlib.sha256(ctx.text.encode("utf-8")).hexdigest()
|
|
||||||
|
|
||||||
# 2. 拼 prompt(指定版本)
|
# 2. 拼 prompt(指定版本)
|
||||||
template = _load_prompt_template(version)
|
template = _load_prompt_template(version)
|
||||||
|
prompt_hash = _prompt_template_hash(version)
|
||||||
user_prompt = template.replace("{{context}}", ctx.text)
|
user_prompt = template.replace("{{context}}", ctx.text)
|
||||||
|
system_prompt = "你是一个严谨的足球预测专家。只输出 JSON。"
|
||||||
|
context_hash = hashlib.sha256(ctx.text.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
# 3. 调 LLM
|
# 3. 调 LLM
|
||||||
|
temperature = 0.3
|
||||||
resp = await provider.chat(
|
resp = await provider.chat(
|
||||||
system="你是一个严谨的足球预测专家。只输出 JSON。",
|
system=system_prompt,
|
||||||
user=user_prompt,
|
user=user_prompt,
|
||||||
json_mode=True,
|
json_mode=True,
|
||||||
temperature=0.3,
|
temperature=temperature,
|
||||||
max_tokens=4096, # 推理模型的 reasoning 也计入输出 token,需留足余量
|
max_tokens=4096, # 推理模型的 reasoning 也计入输出 token,需留足余量
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -399,15 +411,20 @@ async def _predict_single(
|
|||||||
if m is None:
|
if m is None:
|
||||||
raise ValueError(f"match {match_id} not found")
|
raise ValueError(f"match {match_id} not found")
|
||||||
|
|
||||||
pred = await _upsert_prediction(
|
pred = await _insert_or_find_by_fingerprint(
|
||||||
session,
|
session,
|
||||||
match_id=match_id,
|
|
||||||
provider_name=settings.LLM_PROVIDER,
|
|
||||||
model=provider.model,
|
|
||||||
mode="single",
|
|
||||||
run_type="backtest" if backtest else "live",
|
|
||||||
values={
|
values={
|
||||||
|
"match_id": match_id,
|
||||||
|
"provider": settings.LLM_PROVIDER,
|
||||||
|
"model": provider.model,
|
||||||
|
"mode": "single",
|
||||||
|
"run_type": "backtest" if backtest else "live",
|
||||||
"prompt_version": version,
|
"prompt_version": version,
|
||||||
|
"prompt_hash": prompt_hash,
|
||||||
|
"system_prompt_hash": hashlib.sha256(system_prompt.encode("utf-8")).hexdigest(),
|
||||||
|
"temperature": temperature,
|
||||||
|
"context_hash": context_hash,
|
||||||
|
"agent_ids": [],
|
||||||
"prompt_tokens": resp.prompt_tokens,
|
"prompt_tokens": resp.prompt_tokens,
|
||||||
"completion_tokens": resp.completion_tokens,
|
"completion_tokens": resp.completion_tokens,
|
||||||
"latency_ms": resp.latency_ms,
|
"latency_ms": resp.latency_ms,
|
||||||
@@ -423,7 +440,6 @@ async def _predict_single(
|
|||||||
"match_kickoff_at": match_kickoff_at,
|
"match_kickoff_at": match_kickoff_at,
|
||||||
"prediction_cutoff_at": prediction_cutoff_at,
|
"prediction_cutoff_at": prediction_cutoff_at,
|
||||||
"prediction_created_at": now,
|
"prediction_created_at": now,
|
||||||
"input_hash": input_hash,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
你的任务: 综合权衡各报告,输出最终预测。
|
你的任务: 综合权衡各报告,输出最终预测。
|
||||||
|
|
||||||
裁决规则:
|
裁决规则:
|
||||||
- 各报告的 confidence 和 data_sufficiency 是采信依据: no_data/error 状态的报告必须忽略,不得编造
|
- 各报告的 subjective_confidence 和 data_sufficiency 是采信依据: no_data/error 状态的报告必须忽略,不得编造
|
||||||
- 5 位专家: 近期状态分析专家 / 攻防数据分析专家 / 主客因素分析专家 / 阵容完整性分析专家 / 历史交锋分析专家
|
- 5 位专家: 近期状态分析专家 / 攻防数据分析专家 / 主客因素分析专家 / 联赛排名分析专家 / 历史交锋分析专家
|
||||||
- 引用专家意见时使用上述全称,不要使用英文代码(form/stats/h2h 等)
|
- 引用专家意见时使用上述全称,不要使用英文代码(form/stats/h2h 等)
|
||||||
- home_edge 是各专家的方向性判断(-1~1),冲突时给出你的权衡理由
|
- home_edge 是各专家的方向性判断(-1~1),冲突时给出你的权衡理由
|
||||||
- agent_weights 体现你对各报告的采信度(0-1,总和无须为 1)
|
- agent_weights 体现你对各报告的采信度(0-1,总和无须为 1)
|
||||||
@@ -23,8 +23,8 @@
|
|||||||
"alt_pred_home_goals": <int 0-10, 备选比分主队进球(第二可能的比分,必须与主选不同)>,
|
"alt_pred_home_goals": <int 0-10, 备选比分主队进球(第二可能的比分,必须与主选不同)>,
|
||||||
"alt_pred_away_goals": <int 0-10, 备选比分客队进球>,
|
"alt_pred_away_goals": <int 0-10, 备选比分客队进球>,
|
||||||
"1x2": "<'1'|'X'|'2'>",
|
"1x2": "<'1'|'X'|'2'>",
|
||||||
"confidence": <0.0-1.0>,
|
"subjective_confidence": <0.0-1.0>,
|
||||||
"reasoning": "<250 字内推理,引用各报告证据>",
|
"reasoning": "<250 字内推理,引用各报告证据>",
|
||||||
"agent_weights": {"form": <0-1>, "stats": <0-1>, "home_away": <0-1>, "injuries": <0-1>, "h2h": <0-1>}
|
"agent_weights": {"form": <0-1>, "stats": <0-1>, "home_away": <0-1>, "standings": <0-1>, "h2h": <0-1>}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
"data_sufficiency": "high|medium|low|none",
|
"data_sufficiency": "high|medium|low|none",
|
||||||
"analysis": "<150 字内分析,含关键事件与走势判断>",
|
"analysis": "<150 字内分析,含关键事件与走势判断>",
|
||||||
"home_edge": <-1.0 到 1.0, 正数=主队状态更好/走势更向上>,
|
"home_edge": <-1.0 到 1.0, 正数=主队状态更好/走势更向上>,
|
||||||
"confidence": <0.0-1.0>,
|
"subjective_confidence": <0.0-1.0>,
|
||||||
"key_evidence": ["<证据1>", "<证据2>"]
|
"key_evidence": ["<证据1>", "<证据2>"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"data_sufficiency": "high|medium|low|none",
|
"data_sufficiency": "high|medium|low|none",
|
||||||
"analysis": "<150 字内分析,提取交手规律>",
|
"analysis": "<150 字内分析,提取交手规律>",
|
||||||
"home_edge": <-1.0 到 1.0, 正数=主队交锋占优>,
|
"home_edge": <-1.0 到 1.0, 正数=主队交锋占优>,
|
||||||
"confidence": <0.0-1.0>,
|
"subjective_confidence": <0.0-1.0>,
|
||||||
"key_evidence": ["<证据1>", "<证据2>"]
|
"key_evidence": ["<证据1>", "<证据2>"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
"data_sufficiency": "high|medium|low|none",
|
"data_sufficiency": "high|medium|low|none",
|
||||||
"analysis": "<150 字内分析,量化主客因素影响>",
|
"analysis": "<150 字内分析,量化主客因素影响>",
|
||||||
"home_edge": <-1.0 到 1.0, 正数=主场优势明显>,
|
"home_edge": <-1.0 到 1.0, 正数=主场优势明显>,
|
||||||
"confidence": <0.0-1.0>,
|
"subjective_confidence": <0.0-1.0>,
|
||||||
"key_evidence": ["<证据1>", "<证据2>"]
|
"key_evidence": ["<证据1>", "<证据2>"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"data_sufficiency": "high|medium|low|none",
|
"data_sufficiency": "high|medium|low|none",
|
||||||
"analysis": "<150 字内分析,量化两队实力差距>",
|
"analysis": "<150 字内分析,量化两队实力差距>",
|
||||||
"home_edge": <-1.0 到 1.0, 正数=主队实力占优>,
|
"home_edge": <-1.0 到 1.0, 正数=主队实力占优>,
|
||||||
"confidence": <0.0-1.0>,
|
"subjective_confidence": <0.0-1.0>,
|
||||||
"key_evidence": ["<证据1>", "<证据2>"]
|
"key_evidence": ["<证据1>", "<证据2>"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"data_sufficiency": "high|medium|low|none",
|
"data_sufficiency": "high|medium|low|none",
|
||||||
"analysis": "<150 字内分析,量化攻防强度>",
|
"analysis": "<150 字内分析,量化攻防强度>",
|
||||||
"home_edge": <-1.0 到 1.0, 正数=主队攻防占优>,
|
"home_edge": <-1.0 到 1.0, 正数=主队攻防占优>,
|
||||||
"confidence": <0.0-1.0>,
|
"subjective_confidence": <0.0-1.0>,
|
||||||
"key_evidence": ["<证据1>", "<证据2>"]
|
"key_evidence": ["<证据1>", "<证据2>"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
"alt_pred_home_goals": "<int 0-10, 备选比分主队进球(第二可能的比分,必须与主选不同)>",
|
"alt_pred_home_goals": "<int 0-10, 备选比分主队进球(第二可能的比分,必须与主选不同)>",
|
||||||
"alt_pred_away_goals": "<int 0-10, 备选比分客队进球>",
|
"alt_pred_away_goals": "<int 0-10, 备选比分客队进球>",
|
||||||
"1x2": "<'1'|'X'|'2'>",
|
"1x2": "<'1'|'X'|'2'>",
|
||||||
"confidence": "<0.0-1.0>",
|
"subjective_confidence": "<0.0-1.0>",
|
||||||
"score_probable": {"home": "<int>", "away": "<int>", "prob": "<float>"},
|
"score_probable": {"home": "<int>", "away": "<int>", "prob": "<float>"},
|
||||||
"reasoning": "<200 字内推理>"
|
"reasoning": "<200 字内推理>"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
"alt_pred_home_goals": "<int 0-10, 备选比分主队进球(第二可能的比分,必须与主选不同)>",
|
"alt_pred_home_goals": "<int 0-10, 备选比分主队进球(第二可能的比分,必须与主选不同)>",
|
||||||
"alt_pred_away_goals": "<int 0-10, 备选比分客队进球>",
|
"alt_pred_away_goals": "<int 0-10, 备选比分客队进球>",
|
||||||
"1x2": "<'1'|'X'|'2'>",
|
"1x2": "<'1'|'X'|'2'>",
|
||||||
"confidence": "<0.0-1.0>",
|
"subjective_confidence": "<0.0-1.0>",
|
||||||
"score_probable": {"home": "<int>", "away": "<int>", "prob": "<float>"},
|
"score_probable": {"home": "<int>", "away": "<int>", "prob": "<float>"},
|
||||||
"reasoning": "<200 字内推理,需引用具体数据>"
|
"reasoning": "<200 字内推理,需引用具体数据>"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ async def _get_form(db, team_id: int, before, *, limit: int = 5) -> list[Match]:
|
|||||||
selectinload(Match.away_team),
|
selectinload(Match.away_team),
|
||||||
)
|
)
|
||||||
.where(Match.match_status == "finished")
|
.where(Match.match_status == "finished")
|
||||||
|
.where(Match.score_status == "known")
|
||||||
.where(Match.home_goals.is_not(None))
|
.where(Match.home_goals.is_not(None))
|
||||||
.where((Match.home_team_id == team_id) | (Match.away_team_id == team_id))
|
.where((Match.home_team_id == team_id) | (Match.away_team_id == team_id))
|
||||||
.order_by(Match.match_date.desc())
|
.order_by(Match.match_date.desc())
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ async def _get_h2h(db, home_id: int, away_id: int, before, *, limit: int = 5) ->
|
|||||||
selectinload(Match.away_team),
|
selectinload(Match.away_team),
|
||||||
)
|
)
|
||||||
.where(Match.match_status == "finished")
|
.where(Match.match_status == "finished")
|
||||||
|
.where(Match.score_status == "known")
|
||||||
.where(Match.home_goals.is_not(None))
|
.where(Match.home_goals.is_not(None))
|
||||||
.where(
|
.where(
|
||||||
((Match.home_team_id == home_id) & (Match.away_team_id == away_id))
|
((Match.home_team_id == home_id) & (Match.away_team_id == away_id))
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ async def _get_home_away(db, team_id: int, side: str, before, *, limit: int = 10
|
|||||||
selectinload(Match.away_team),
|
selectinload(Match.away_team),
|
||||||
)
|
)
|
||||||
.where(Match.match_status == "finished")
|
.where(Match.match_status == "finished")
|
||||||
|
.where(Match.score_status == "known")
|
||||||
.where(Match.home_goals.is_not(None))
|
.where(Match.home_goals.is_not(None))
|
||||||
.order_by(Match.match_date.desc())
|
.order_by(Match.match_date.desc())
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""D - 联赛排名切片: 积分榜位置与实力差距(standings)。"""
|
"""D - 联赛排名切片: 积分榜快照(支持 cutoff 的历史还原,standings)。"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
@@ -7,6 +7,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from src.db.base import AsyncSessionLocal
|
from src.db.base import AsyncSessionLocal
|
||||||
|
from src.db.models import League, Standing
|
||||||
from src.llm.slices.common import MatchHeader, SliceResult
|
from src.llm.slices.common import MatchHeader, SliceResult
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -16,24 +17,33 @@ if TYPE_CHECKING:
|
|||||||
async def standings_slice(header: MatchHeader, *, before=None, db: AsyncSession | None = None) -> SliceResult:
|
async def standings_slice(header: MatchHeader, *, before=None, db: AsyncSession | None = None) -> SliceResult:
|
||||||
"""D - 联赛排名切片: 两队积分榜位置、积分、近期走势(form)、分区,评估整体实力差距。
|
"""D - 联赛排名切片: 两队积分榜位置、积分、近期走势(form)、分区,评估整体实力差距。
|
||||||
|
|
||||||
before 参数保留与其他切片一致的签名(积分榜是最新快照,无历史版本,不受 cutoff 影响)。
|
P0-02: 支持 cutoff(before)。取 available_at<=cutoff 的每队最新快照
|
||||||
|
(DISTINCT ON team_id ORDER available_at DESC);before=None 时 cutoff=now()。
|
||||||
|
回测时可还原历史时刻榜单,不再只是"最新快照、忽略 cutoff"。
|
||||||
|
|
||||||
db: 可选共享 session(见 context_builder 模块 docstring)。
|
db: 可选共享 session(见 context_builder 模块 docstring)。
|
||||||
|
|
||||||
语义区分:
|
语义区分:
|
||||||
- 两队都有积分榜行 → has_data=True(明确的排名信息)
|
- 两队都有积分榜行 → has_data=True(明确的排名信息)
|
||||||
- 任一队缺失 → has_data=False(升班马/杯赛无榜,信息不完整时明确声明)
|
- 任一队缺失 → has_data=False(升班马/杯赛无榜,信息不完整时明确声明)
|
||||||
"""
|
"""
|
||||||
from src.db.models import League, Standing
|
# P0-02: before=None → cutoff=now()(取最新可用快照)
|
||||||
|
if before is None:
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
before = datetime.now(timezone.utc)
|
||||||
|
|
||||||
if db is not None:
|
if db is not None:
|
||||||
league = (await db.execute(select(League).where(League.id == header.league_id))).scalar_one_or_none()
|
league = (await db.execute(select(League).where(League.id == header.league_id))).scalar_one_or_none()
|
||||||
|
# P0-02: DISTINCT ON (team_id) 取 available_at<=cutoff 的最新快照
|
||||||
rows = (
|
rows = (
|
||||||
(
|
(
|
||||||
await db.execute(
|
await db.execute(
|
||||||
select(Standing)
|
select(Standing)
|
||||||
.options(selectinload(Standing.team))
|
.options(selectinload(Standing.team))
|
||||||
.where(Standing.league_id == header.league_id)
|
.where(Standing.league_id == header.league_id)
|
||||||
.order_by(Standing.position.asc())
|
.where(Standing.available_at <= before)
|
||||||
|
.distinct(Standing.team_id)
|
||||||
|
.order_by(Standing.team_id, Standing.available_at.desc())
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.scalars()
|
.scalars()
|
||||||
@@ -49,7 +59,7 @@ async def standings_slice(header: MatchHeader, *, before=None, db: AsyncSession
|
|||||||
n_records = 0
|
n_records = 0
|
||||||
|
|
||||||
def _fmt(row) -> str:
|
def _fmt(row) -> str:
|
||||||
zg = f" xG差 {row.xgd:+.1f}" if row.xg_for is not None and row.xg_against is not None and row.goal_diff is not None else ""
|
zg = f" xG差 {row.xg_for - row.xg_against:+.1f}" if row.xg_for is not None and row.xg_against is not None else ""
|
||||||
form = f" 近5场 {row.form}" if row.form else ""
|
form = f" 近5场 {row.form}" if row.form else ""
|
||||||
zone = f" [{row.zone}]" if row.zone else ""
|
zone = f" [{row.zone}]" if row.zone else ""
|
||||||
return (
|
return (
|
||||||
|
|||||||
+1
-9
@@ -3,17 +3,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
|
|
||||||
def actual_1x2(home: int, away: int) -> str:
|
def actual_1x2(home: int, away: int) -> str:
|
||||||
"""实际比分 → 胜平负。
|
"""实际比分 → 胜平负(单一权威源:backtest.py 与 eval.py 共用)。"""
|
||||||
|
|
||||||
单一权威源: backtest.py 和 eval.py 共用,避免重复定义。
|
|
||||||
"""
|
|
||||||
if home > away:
|
if home > away:
|
||||||
return "1"
|
return "1"
|
||||||
if home == away:
|
if home == away:
|
||||||
return "X"
|
return "X"
|
||||||
return "2"
|
return "2"
|
||||||
|
|
||||||
|
|
||||||
def is_correct_1x2(pred: str | None, actual: str) -> bool:
|
|
||||||
"""预测是否命中胜平负。"""
|
|
||||||
return pred == actual
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ class TestOrchestratorWritesAgentWeights:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_orchestrator_writes_agent_weights_to_upsert(self):
|
async def test_orchestrator_writes_agent_weights_to_upsert(self):
|
||||||
"""orchestrator 应将 agent_weights 传入 _upsert_prediction。"""
|
"""orchestrator 应将 agent_weights 传入 _insert_or_find_by_fingerprint。"""
|
||||||
from src.llm.agents import orchestrator as orch_mod
|
from src.llm.agents import orchestrator as orch_mod
|
||||||
from src.llm.agents.base import AgentReport
|
from src.llm.agents.base import AgentReport
|
||||||
from src.llm.context_builder import MatchHeader
|
from src.llm.context_builder import MatchHeader
|
||||||
@@ -103,8 +103,8 @@ class TestOrchestratorWritesAgentWeights:
|
|||||||
"agent_weights": {"form": 0.3, "home_away": 0.5, "stats": 0.2},
|
"agent_weights": {"form": 0.3, "home_away": 0.5, "stats": 0.2},
|
||||||
}, 100, 50
|
}, 100, 50
|
||||||
|
|
||||||
async def mock_upsert(session, **kw):
|
async def mock_upsert(session, *, values):
|
||||||
captured_values.update(kw.get("values", {}))
|
captured_values.update(values)
|
||||||
p = MagicMock()
|
p = MagicMock()
|
||||||
p.id = 1
|
p.id = 1
|
||||||
p.provider = "test"
|
p.provider = "test"
|
||||||
@@ -116,7 +116,7 @@ class TestOrchestratorWritesAgentWeights:
|
|||||||
p.subjective_confidence = 0.7
|
p.subjective_confidence = 0.7
|
||||||
p.reasoning = "test"
|
p.reasoning = "test"
|
||||||
p.agent_outputs = []
|
p.agent_outputs = []
|
||||||
p.agent_weights = kw["values"].get("agent_weights")
|
p.agent_weights = values.get("agent_weights")
|
||||||
return p
|
return p
|
||||||
|
|
||||||
class FakeUow:
|
class FakeUow:
|
||||||
@@ -130,14 +130,14 @@ class TestOrchestratorWritesAgentWeights:
|
|||||||
with patch.object(orch_mod, "run_specialists", mock_specialists), \
|
with patch.object(orch_mod, "run_specialists", mock_specialists), \
|
||||||
patch.object(orch_mod, "_agent_provider", mock_provider), \
|
patch.object(orch_mod, "_agent_provider", mock_provider), \
|
||||||
patch.object(orch_mod, "load_match_header", mock_header), \
|
patch.object(orch_mod, "load_match_header", mock_header), \
|
||||||
patch.object(orch_mod, "_upsert_prediction", mock_upsert), \
|
patch.object(orch_mod, "_insert_or_find_by_fingerprint", mock_upsert), \
|
||||||
patch.object(orch_mod, "run_aggregator", mock_aggregator), \
|
patch.object(orch_mod, "run_aggregator", mock_aggregator), \
|
||||||
patch.object(orch_mod, "get_uow", FakeUow):
|
patch.object(orch_mod, "get_uow", FakeUow):
|
||||||
|
|
||||||
result = await orch_mod.predict_match_multi(999)
|
result = await orch_mod.predict_match_multi(999)
|
||||||
|
|
||||||
# 断言 agent_weights 被写入
|
# 断言 agent_weights 被写入
|
||||||
assert "agent_weights" in captured_values, "agent_weights 应传入 _upsert_prediction"
|
assert "agent_weights" in captured_values, "agent_weights 应传入 _insert_or_find_by_fingerprint"
|
||||||
assert captured_values["agent_weights"] is not None, "agent_weights 不应为 None"
|
assert captured_values["agent_weights"] is not None, "agent_weights 不应为 None"
|
||||||
assert "form" in captured_values["agent_weights"], "agent_weights 应包含专家权重"
|
assert "form" in captured_values["agent_weights"], "agent_weights 应包含专家权重"
|
||||||
print(f"PASS: agent_weights = {captured_values['agent_weights']}")
|
print(f"PASS: agent_weights = {captured_values['agent_weights']}")
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ async def test_predict_baseline_no_llm():
|
|||||||
with patch("src.llm.baseline._avg_goals", fake_avg), \
|
with patch("src.llm.baseline._avg_goals", fake_avg), \
|
||||||
patch("src.llm.baseline.AsyncSessionLocal") as SLC, \
|
patch("src.llm.baseline.AsyncSessionLocal") as SLC, \
|
||||||
patch("src.db.unit_of_work.get_uow", _FakeUoW), \
|
patch("src.db.unit_of_work.get_uow", _FakeUoW), \
|
||||||
patch("src.llm.baseline._upsert_prediction", _fake_upsert):
|
patch("src.llm.baseline._insert_or_find_by_fingerprint", _fake_upsert):
|
||||||
class FakeSession:
|
class FakeSession:
|
||||||
async def get(self, cls, mid):
|
async def get(self, cls, mid):
|
||||||
return FakeMatch()
|
return FakeMatch()
|
||||||
@@ -137,7 +137,7 @@ async def test_predict_baseline_clamps_to_range():
|
|||||||
with patch("src.llm.baseline._avg_goals", fake_avg), \
|
with patch("src.llm.baseline._avg_goals", fake_avg), \
|
||||||
patch("src.llm.baseline.AsyncSessionLocal") as SLC, \
|
patch("src.llm.baseline.AsyncSessionLocal") as SLC, \
|
||||||
patch("src.db.unit_of_work.get_uow", _FakeUoW), \
|
patch("src.db.unit_of_work.get_uow", _FakeUoW), \
|
||||||
patch("src.llm.baseline._upsert_prediction", _fake_upsert):
|
patch("src.llm.baseline._insert_or_find_by_fingerprint", _fake_upsert):
|
||||||
class FakeSession:
|
class FakeSession:
|
||||||
async def get(self, cls, mid):
|
async def get(self, cls, mid):
|
||||||
return FakeMatch()
|
return FakeMatch()
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ async def test_predict_baseline_returns_predict_result():
|
|||||||
async def __aexit__(self, *a):
|
async def __aexit__(self, *a):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# P3-2:baseline 在服务层落库(get_uow + _upsert_prediction),需 mock 掉。
|
# P3-2:baseline 在服务层落库(get_uow + _insert_or_find_by_fingerprint),需 mock 掉。
|
||||||
class FakeUoW:
|
class FakeUoW:
|
||||||
async def __aenter__(self):
|
async def __aenter__(self):
|
||||||
return _make_session()
|
return _make_session()
|
||||||
@@ -72,24 +72,24 @@ async def test_predict_baseline_returns_predict_result():
|
|||||||
|
|
||||||
captured = {}
|
captured = {}
|
||||||
|
|
||||||
async def fake_upsert(session, **kw):
|
async def fake_upsert(session, *, values):
|
||||||
captured.update(kw)
|
captured.update(values)
|
||||||
return SimpleNamespace(id=77)
|
return SimpleNamespace(id=77)
|
||||||
|
|
||||||
# baseline.py 内部 from-import get_uow / _upsert_prediction,需 patch 真实来源模块。
|
# baseline.py 内部 from-import get_uow / _insert_or_find_by_fingerprint,需 patch 真实来源模块。
|
||||||
with patch("src.llm.baseline._avg_goals", fake_avg), \
|
with patch("src.llm.baseline._avg_goals", fake_avg), \
|
||||||
patch("src.llm.baseline.AsyncSessionLocal") as SLC, \
|
patch("src.llm.baseline.AsyncSessionLocal") as SLC, \
|
||||||
patch("src.db.unit_of_work.get_uow", FakeUoW), \
|
patch("src.db.unit_of_work.get_uow", FakeUoW), \
|
||||||
patch("src.llm.baseline._upsert_prediction", fake_upsert):
|
patch("src.llm.baseline._insert_or_find_by_fingerprint", fake_upsert):
|
||||||
SLC.return_value = FakeCM()
|
SLC.return_value = FakeCM()
|
||||||
|
|
||||||
result = await predict_baseline(1)
|
result = await predict_baseline(1)
|
||||||
|
|
||||||
# P3-2:验证服务层落库被调用且属性映射正确
|
# P3-2:验证服务层落库被调用且属性映射正确
|
||||||
assert captured["match_id"] == 1
|
assert captured["match_id"] == 1
|
||||||
assert captured["provider_name"] == "baseline"
|
assert captured["provider"] == "baseline"
|
||||||
assert captured["run_type"] == "live"
|
assert captured["run_type"] == "live"
|
||||||
assert captured["values"]["pred_home_goals"] == 2.0
|
assert captured["pred_home_goals"] == 2.0
|
||||||
|
|
||||||
assert isinstance(result, PredictResult)
|
assert isinstance(result, PredictResult)
|
||||||
assert result.mode == "baseline"
|
assert result.mode == "baseline"
|
||||||
@@ -225,8 +225,8 @@ async def test_baseline_service_persists_with_correct_attributes(monkeypatch):
|
|||||||
"""P3-2:baseline 在服务层(predict_baseline)落库,属性映射与路由旧版一致。"""
|
"""P3-2:baseline 在服务层(predict_baseline)落库,属性映射与路由旧版一致。"""
|
||||||
captured = {}
|
captured = {}
|
||||||
|
|
||||||
async def fake_upsert(session, **kwargs):
|
async def fake_upsert(session, *, values):
|
||||||
captured.update(kwargs)
|
captured.update(values)
|
||||||
return SimpleNamespace(id=77)
|
return SimpleNamespace(id=77)
|
||||||
|
|
||||||
class FakeMatch:
|
class FakeMatch:
|
||||||
@@ -253,49 +253,47 @@ async def test_baseline_service_persists_with_correct_attributes(monkeypatch):
|
|||||||
monkeypatch.setattr("src.llm.baseline._avg_goals", fake_avg)
|
monkeypatch.setattr("src.llm.baseline._avg_goals", fake_avg)
|
||||||
monkeypatch.setattr("src.llm.baseline.AsyncSessionLocal", FakeSLC)
|
monkeypatch.setattr("src.llm.baseline.AsyncSessionLocal", FakeSLC)
|
||||||
monkeypatch.setattr("src.db.unit_of_work.get_uow", lambda: _FakeUoW())
|
monkeypatch.setattr("src.db.unit_of_work.get_uow", lambda: _FakeUoW())
|
||||||
# baseline.py 模块级 import _upsert_prediction(第 15 行),需 patch baseline 模块属性
|
# baseline.py 模块级 import _insert_or_find_by_fingerprint(第 15 行),需 patch baseline 模块属性
|
||||||
monkeypatch.setattr("src.llm.baseline._upsert_prediction", fake_upsert)
|
monkeypatch.setattr("src.llm.baseline._insert_or_find_by_fingerprint", fake_upsert)
|
||||||
|
|
||||||
result = await predict_baseline(1)
|
result = await predict_baseline(1)
|
||||||
|
|
||||||
# 落库被调用且属性映射正确
|
# 落库被调用且属性映射正确
|
||||||
assert captured, f"predict_baseline 应调用 _upsert_prediction 落库,但 captured 为空(result.prediction_id={result.prediction_id!r})"
|
assert captured, f"predict_baseline 应调用 _insert_or_find_by_fingerprint 落库,但 captured 为空(result.prediction_id={result.prediction_id!r})"
|
||||||
assert captured["match_id"] == 1
|
assert captured["match_id"] == 1
|
||||||
assert captured["provider_name"] == "baseline"
|
assert captured["provider"] == "baseline"
|
||||||
assert captured["model"] == "baseline"
|
assert captured["model"] == "baseline"
|
||||||
assert captured["mode"] == "baseline"
|
assert captured["mode"] == "baseline"
|
||||||
assert captured["run_type"] == "live"
|
assert captured["run_type"] == "live"
|
||||||
v = captured["values"]
|
assert captured["prompt_version"] == "baseline_v1"
|
||||||
assert v["prompt_version"] == "baseline_v1"
|
assert captured["pred_home_goals"] == 2.0
|
||||||
assert v["pred_home_goals"] == 2.0
|
assert captured["pred_away_goals"] == 1.0
|
||||||
assert v["pred_away_goals"] == 1.0
|
assert captured["pred_1x2"] == "1"
|
||||||
assert v["pred_1x2"] == "1"
|
assert captured["subjective_confidence"] == 0.5
|
||||||
assert v["subjective_confidence"] == 0.5
|
assert captured["prompt_tokens"] == 0
|
||||||
assert v["prompt_tokens"] == 0
|
assert captured["completion_tokens"] == 0
|
||||||
assert v["completion_tokens"] == 0
|
assert captured["latency_ms"] == 0
|
||||||
assert v["latency_ms"] == 0
|
assert captured["raw_response"] == {"home_avg": 2.0, "away_avg": 1.0}
|
||||||
assert v["raw_response"] == {"home_avg": 2.0, "away_avg": 1.0}
|
assert captured["status"] == "success"
|
||||||
assert v["status"] == "success"
|
|
||||||
|
|
||||||
# 回填真实 prediction_id(服务层落库后取得)
|
# 回填真实 prediction_id(服务层落库后取得)
|
||||||
assert result.prediction_id == 77
|
assert result.prediction_id == 77
|
||||||
assert result.pred_1x2 == "1"
|
assert result.pred_1x2 == "1"
|
||||||
assert captured["match_id"] == 1
|
assert captured["match_id"] == 1
|
||||||
assert captured["provider_name"] == "baseline"
|
assert captured["provider"] == "baseline"
|
||||||
assert captured["model"] == "baseline"
|
assert captured["model"] == "baseline"
|
||||||
assert captured["mode"] == "baseline"
|
assert captured["mode"] == "baseline"
|
||||||
assert captured["run_type"] == "live"
|
assert captured["run_type"] == "live"
|
||||||
v = captured["values"]
|
assert captured["prompt_version"] == "baseline_v1"
|
||||||
assert v["prompt_version"] == "baseline_v1"
|
assert captured["pred_home_goals"] == 2.0
|
||||||
assert v["pred_home_goals"] == 2.0
|
assert captured["pred_away_goals"] == 1.0
|
||||||
assert v["pred_away_goals"] == 1.0
|
assert captured["pred_1x2"] == "1"
|
||||||
assert v["pred_1x2"] == "1"
|
assert captured["subjective_confidence"] == 0.5
|
||||||
assert v["subjective_confidence"] == 0.5
|
assert captured["prompt_tokens"] == 0
|
||||||
assert v["prompt_tokens"] == 0
|
assert captured["completion_tokens"] == 0
|
||||||
assert v["completion_tokens"] == 0
|
assert captured["latency_ms"] == 0
|
||||||
assert v["latency_ms"] == 0
|
assert captured["raw_response"] == {"home_avg": 2.0, "away_avg": 1.0}
|
||||||
assert v["raw_response"] == {"home_avg": 2.0, "away_avg": 1.0}
|
assert captured["status"] == "success"
|
||||||
assert v["status"] == "success"
|
|
||||||
|
|
||||||
# 回填真实 prediction_id(服务层落库后取得)
|
# 回填真实 prediction_id(服务层落库后取得)
|
||||||
assert result.prediction_id == 77
|
assert result.prediction_id == 77
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ class _FakeDB:
|
|||||||
RawEvent: list(raw_events),
|
RawEvent: list(raw_events),
|
||||||
}
|
}
|
||||||
self._teams_by_id: dict[int, Team] = {t.id: t for t in teams if getattr(t, "id", None)}
|
self._teams_by_id: dict[int, Team] = {t.id: t for t in teams if getattr(t, "id", None)}
|
||||||
|
self._teams_by_name: dict[str, Team] = {t.name: t for t in teams if getattr(t, "id", None)}
|
||||||
self._aliases: dict[str, TeamAlias] = {}
|
self._aliases: dict[str, TeamAlias] = {}
|
||||||
self._next_id = max((t.id for t in teams if getattr(t, "id", None)), default=0)
|
self._next_id = max((t.id for t in teams if getattr(t, "id", None)), default=0)
|
||||||
|
|
||||||
@@ -86,6 +87,52 @@ class _FakeDB:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
async def execute(self, stmt):
|
async def execute(self, stmt):
|
||||||
|
# P1-K:LeagueRepository/TeamRepository.get_or_create 使用 pg_insert(Insert 语句,无 column_descriptions)
|
||||||
|
if not hasattr(stmt, "column_descriptions"):
|
||||||
|
# 解析 INSERT 值并注册到 _by_entity,使二次查询可命中
|
||||||
|
try:
|
||||||
|
values = stmt.compile().params
|
||||||
|
except Exception:
|
||||||
|
values = {}
|
||||||
|
table = getattr(stmt, "table", None)
|
||||||
|
table_name = getattr(table, "name", None) if table is not None else None
|
||||||
|
entity_map = {"leagues": League, "teams": Team}
|
||||||
|
entity = entity_map.get(table_name)
|
||||||
|
if entity is not None:
|
||||||
|
obj = entity()
|
||||||
|
for k, v in values.items():
|
||||||
|
if k in ("code", "name", "country", "name_zh"):
|
||||||
|
setattr(obj, k, v)
|
||||||
|
if getattr(obj, "id", None) is None:
|
||||||
|
self._next_id += 1
|
||||||
|
obj.id = self._next_id
|
||||||
|
if entity not in self._by_entity:
|
||||||
|
self._by_entity[entity] = []
|
||||||
|
self._by_entity[entity].append(obj)
|
||||||
|
if entity is Team:
|
||||||
|
self._teams_by_id[obj.id] = obj
|
||||||
|
self._teams_by_name[obj.name] = obj
|
||||||
|
class _Empty:
|
||||||
|
def scalar_one_or_none(self_inner):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def scalars(self_inner):
|
||||||
|
return self_inner
|
||||||
|
|
||||||
|
def all(self_inner):
|
||||||
|
return []
|
||||||
|
|
||||||
|
return _Empty()
|
||||||
|
# P1-K:Team 查询回退到内存映射 name(避免队列/实体分发不完全匹配)
|
||||||
|
try:
|
||||||
|
params = stmt.compile().params
|
||||||
|
except Exception:
|
||||||
|
params = {}
|
||||||
|
name_val = next((v for k, v in params.items() if k.startswith("name")), None)
|
||||||
|
if isinstance(name_val, str) and name_val:
|
||||||
|
team = self._teams_by_name.get(name_val)
|
||||||
|
if team:
|
||||||
|
return _FakeResult([team])
|
||||||
entities = set()
|
entities = set()
|
||||||
for d in (stmt.column_descriptions or []):
|
for d in (stmt.column_descriptions or []):
|
||||||
entities.add(d.get("entity") or d.get("type"))
|
entities.add(d.get("entity") or d.get("type"))
|
||||||
@@ -94,6 +141,10 @@ class _FakeDB:
|
|||||||
return _FakeResult(items)
|
return _FakeResult(items)
|
||||||
return _FakeResult([])
|
return _FakeResult([])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _filter(entity, items, stmt):
|
||||||
|
return items
|
||||||
|
|
||||||
async def flush(self):
|
async def flush(self):
|
||||||
for obj in self.added:
|
for obj in self.added:
|
||||||
if getattr(obj, "id", None) is None:
|
if getattr(obj, "id", None) is None:
|
||||||
@@ -261,8 +312,8 @@ class TestEventsBronzeIsBestEffort:
|
|||||||
raise RuntimeError("infra down")
|
raise RuntimeError("infra down")
|
||||||
|
|
||||||
# Bronze 写入助手直接 import 到 bzzoiro_events 命名空间,需 patch 该处
|
# Bronze 写入助手直接 import 到 bzzoiro_events 命名空间,需 patch 该处
|
||||||
monkeypatch.setattr(bz_events, "_write_raw_event", _boom)
|
monkeypatch.setattr("src.data.pipeline_write._write_raw_event", _boom)
|
||||||
monkeypatch.setattr(bz_events, "_write_lineage", _boom)
|
monkeypatch.setattr("src.data.pipeline_write._write_lineage", _boom)
|
||||||
_patch_fetch(monkeypatch, [_event()])
|
_patch_fetch(monkeypatch, [_event()])
|
||||||
db = _FakeDB()
|
db = _FakeDB()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""日志持久化(LOG_FILE)测试:setup_logging 启用滚动文件后日志必须落盘。
|
||||||
|
|
||||||
|
背景: 此前应用日志只进 stdout(docker json-file 收集,不可控) + Admin 内存
|
||||||
|
日志页(环形缓冲 2000 条,进程重启清零),没有任何应用层持久化 —— 排查
|
||||||
|
「昨晚采集为什么失败」这类问题时无据可查。本测试守护:
|
||||||
|
1. 传 log_file → root logger 挂 RotatingFileHandler,日志写入文件
|
||||||
|
2. 幂等:重复调用不重复挂 handler
|
||||||
|
3. log_file 为空 → 不挂文件 handler(保持旧行为)
|
||||||
|
4. 目录不存在 → 自动创建
|
||||||
|
5. 文件打开失败(如路径是已存在的目录) → 只降级不炸,stdout/内存日志仍在
|
||||||
|
|
||||||
|
范式: 直接操作 root logger + tmp_path;fixture 保存/恢复 root 状态,
|
||||||
|
并关闭新增 handler 的文件句柄(Windows 上句柄不关会锁住 tmp 目录)。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.core.log_buffer import MemoryLogHandler, setup_logging
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _restore_root_logger():
|
||||||
|
"""保存/恢复 root logger;关闭测试期间新挂 handler 的句柄。"""
|
||||||
|
root = logging.getLogger()
|
||||||
|
saved_handlers = list(root.handlers)
|
||||||
|
saved_level = root.level
|
||||||
|
yield
|
||||||
|
for h in root.handlers:
|
||||||
|
if h not in saved_handlers and hasattr(h, "close"):
|
||||||
|
try:
|
||||||
|
h.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
root.handlers[:] = saved_handlers
|
||||||
|
root.setLevel(saved_level)
|
||||||
|
|
||||||
|
|
||||||
|
def _file_handlers():
|
||||||
|
return [h for h in logging.getLogger().handlers if isinstance(h, RotatingFileHandler)]
|
||||||
|
|
||||||
|
|
||||||
|
def _memory_handlers():
|
||||||
|
return [h for h in logging.getLogger().handlers if isinstance(h, MemoryLogHandler)]
|
||||||
|
|
||||||
|
|
||||||
|
class TestFileHandlerAttached:
|
||||||
|
def test_attaches_rotating_file_handler(self, tmp_path):
|
||||||
|
log_file = tmp_path / "app.log"
|
||||||
|
|
||||||
|
setup_logging("INFO", str(log_file))
|
||||||
|
|
||||||
|
fhs = _file_handlers()
|
||||||
|
assert len(fhs) == 1
|
||||||
|
assert fhs[0].baseFilename == str(log_file)
|
||||||
|
# 内存日志页照常工作,两者并存
|
||||||
|
assert len(_memory_handlers()) == 1
|
||||||
|
# 滚动参数与文档口径一致: 单文件 10MB × 5 份
|
||||||
|
assert fhs[0].maxBytes == 10 * 1024 * 1024
|
||||||
|
assert fhs[0].backupCount == 5
|
||||||
|
|
||||||
|
def test_log_written_to_file(self, tmp_path):
|
||||||
|
log_file = tmp_path / "app.log"
|
||||||
|
setup_logging("INFO", str(log_file))
|
||||||
|
|
||||||
|
logging.getLogger("persist-test").info("hello-persist-12345")
|
||||||
|
|
||||||
|
content = log_file.read_text(encoding="utf-8")
|
||||||
|
assert "hello-persist-12345" in content
|
||||||
|
assert "persist-test" in content # logger 名可追溯
|
||||||
|
assert "INFO" in content # 级别在行首可过滤
|
||||||
|
|
||||||
|
|
||||||
|
class TestIdempotent:
|
||||||
|
def test_repeated_call_does_not_duplicate_handlers(self, tmp_path):
|
||||||
|
log_file = tmp_path / "app.log"
|
||||||
|
|
||||||
|
setup_logging("INFO", str(log_file))
|
||||||
|
setup_logging("INFO", str(log_file))
|
||||||
|
setup_logging("INFO", str(log_file))
|
||||||
|
|
||||||
|
assert len(_file_handlers()) == 1
|
||||||
|
assert len(_memory_handlers()) == 1
|
||||||
|
|
||||||
|
def test_same_file_via_relative_and_absolute_path_counts_as_one(self, tmp_path, monkeypatch):
|
||||||
|
"""相对/绝对路径指向同一文件时不得重复挂(幂等按 abspath 判重)。"""
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
|
||||||
|
setup_logging("INFO", "app.log")
|
||||||
|
setup_logging("INFO", str(tmp_path / "app.log"))
|
||||||
|
|
||||||
|
assert len(_file_handlers()) == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestDisabled:
|
||||||
|
def test_empty_log_file_keeps_old_behavior(self):
|
||||||
|
setup_logging("INFO", "")
|
||||||
|
|
||||||
|
assert _file_handlers() == []
|
||||||
|
assert len(_memory_handlers()) == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestAutoMkdir:
|
||||||
|
def test_creates_missing_directories(self, tmp_path):
|
||||||
|
log_file = tmp_path / "deep" / "nested" / "app.log"
|
||||||
|
|
||||||
|
setup_logging("INFO", str(log_file))
|
||||||
|
|
||||||
|
assert len(_file_handlers()) == 1
|
||||||
|
assert log_file.parent.is_dir()
|
||||||
|
|
||||||
|
|
||||||
|
class TestGracefulDegradation:
|
||||||
|
def test_unopenable_path_degrades_without_raising(self, tmp_path):
|
||||||
|
"""路径是已存在的目录 → 打开必然失败;只降级,不炸启动。"""
|
||||||
|
dir_as_file = tmp_path / "occupied"
|
||||||
|
dir_as_file.mkdir()
|
||||||
|
|
||||||
|
# 不应抛异常(文件日志是可观测性基础设施,失败只 warning)
|
||||||
|
setup_logging("INFO", str(dir_as_file))
|
||||||
|
|
||||||
|
assert _file_handlers() == []
|
||||||
|
# 降级后 stdout/内存日志路径仍在
|
||||||
|
assert len(_memory_handlers()) == 1
|
||||||
@@ -77,7 +77,7 @@ class TestAllExpertsFailed:
|
|||||||
async def mock_load_header(mid, db=None):
|
async def mock_load_header(mid, db=None):
|
||||||
return header
|
return header
|
||||||
|
|
||||||
# Mock _upsert_prediction — 捕获写入的 status
|
# Mock _insert_or_find_by_fingerprint — 捕获写入的 status
|
||||||
captured_status = {}
|
captured_status = {}
|
||||||
|
|
||||||
async def mock_upsert(session, **kw):
|
async def mock_upsert(session, **kw):
|
||||||
@@ -109,7 +109,7 @@ class TestAllExpertsFailed:
|
|||||||
with patch.object(orch_mod, "run_specialists", mock_run_specialists), \
|
with patch.object(orch_mod, "run_specialists", mock_run_specialists), \
|
||||||
patch.object(orch_mod, "_agent_provider", mock_agent_provider), \
|
patch.object(orch_mod, "_agent_provider", mock_agent_provider), \
|
||||||
patch.object(orch_mod, "load_match_header", mock_load_header), \
|
patch.object(orch_mod, "load_match_header", mock_load_header), \
|
||||||
patch.object(orch_mod, "_upsert_prediction", mock_upsert), \
|
patch.object(orch_mod, "_insert_or_find_by_fingerprint", mock_upsert), \
|
||||||
patch.object(orch_mod, "get_uow", FakeUow):
|
patch.object(orch_mod, "get_uow", FakeUow):
|
||||||
|
|
||||||
result = await orch_mod.predict_match_multi(999)
|
result = await orch_mod.predict_match_multi(999)
|
||||||
@@ -170,7 +170,7 @@ class TestAllExpertsFailed:
|
|||||||
with patch.object(orch_mod, "run_specialists", mock_run_specialists), \
|
with patch.object(orch_mod, "run_specialists", mock_run_specialists), \
|
||||||
patch.object(orch_mod, "_agent_provider", mock_agent_provider), \
|
patch.object(orch_mod, "_agent_provider", mock_agent_provider), \
|
||||||
patch.object(orch_mod, "load_match_header", mock_load_header), \
|
patch.object(orch_mod, "load_match_header", mock_load_header), \
|
||||||
patch.object(orch_mod, "_upsert_prediction", mock_upsert), \
|
patch.object(orch_mod, "_insert_or_find_by_fingerprint", mock_upsert), \
|
||||||
patch.object(orch_mod, "get_uow", FakeUow):
|
patch.object(orch_mod, "get_uow", FakeUow):
|
||||||
|
|
||||||
result = await orch_mod.predict_match_multi(999)
|
result = await orch_mod.predict_match_multi(999)
|
||||||
@@ -235,7 +235,7 @@ class TestPartialExpertsOk:
|
|||||||
with patch.object(orch_mod, "run_specialists", mock_run_specialists), \
|
with patch.object(orch_mod, "run_specialists", mock_run_specialists), \
|
||||||
patch.object(orch_mod, "_agent_provider", mock_agent_provider), \
|
patch.object(orch_mod, "_agent_provider", mock_agent_provider), \
|
||||||
patch.object(orch_mod, "load_match_header", mock_load_header), \
|
patch.object(orch_mod, "load_match_header", mock_load_header), \
|
||||||
patch.object(orch_mod, "_upsert_prediction", mock_upsert), \
|
patch.object(orch_mod, "_insert_or_find_by_fingerprint", mock_upsert), \
|
||||||
patch.object(orch_mod, "run_aggregator", mock_aggregator), \
|
patch.object(orch_mod, "run_aggregator", mock_aggregator), \
|
||||||
patch.object(orch_mod, "get_uow", FakeUow):
|
patch.object(orch_mod, "get_uow", FakeUow):
|
||||||
|
|
||||||
@@ -268,7 +268,7 @@ class TestNoAggregatorCallOnDegraded:
|
|||||||
captured_values = {}
|
captured_values = {}
|
||||||
|
|
||||||
async def mock_upsert(session, **kw):
|
async def mock_upsert(session, **kw):
|
||||||
# model / provider_name / mode 是 _upsert_prediction 的顶层关键字参数,
|
# model / provider_name / mode 是 _insert_or_find_by_fingerprint 的顶层关键字参数,
|
||||||
# 不在 values 字典里(见 orchestrator.py 的调用点)。原测试只取
|
# 不在 values 字典里(见 orchestrator.py 的调用点)。原测试只取
|
||||||
# kw["values"],导致 model 断言永远为 None。
|
# kw["values"],导致 model 断言永远为 None。
|
||||||
captured_values.update(kw.get("values", {}))
|
captured_values.update(kw.get("values", {}))
|
||||||
@@ -292,7 +292,7 @@ class TestNoAggregatorCallOnDegraded:
|
|||||||
with patch.object(orch_mod, "run_specialists", mock_run_specialists), \
|
with patch.object(orch_mod, "run_specialists", mock_run_specialists), \
|
||||||
patch.object(orch_mod, "_agent_provider", mock_agent_provider), \
|
patch.object(orch_mod, "_agent_provider", mock_agent_provider), \
|
||||||
patch.object(orch_mod, "load_match_header", mock_load_header), \
|
patch.object(orch_mod, "load_match_header", mock_load_header), \
|
||||||
patch.object(orch_mod, "_upsert_prediction", mock_upsert), \
|
patch.object(orch_mod, "_insert_or_find_by_fingerprint", mock_upsert), \
|
||||||
patch.object(orch_mod, "get_uow", FakeUow):
|
patch.object(orch_mod, "get_uow", FakeUow):
|
||||||
|
|
||||||
await orch_mod.predict_match_multi(999)
|
await orch_mod.predict_match_multi(999)
|
||||||
@@ -300,7 +300,6 @@ class TestNoAggregatorCallOnDegraded:
|
|||||||
# 断言:aggregator provider 未被调用
|
# 断言:aggregator provider 未被调用
|
||||||
assert len(aggregator_called) == 0, \
|
assert len(aggregator_called) == 0, \
|
||||||
f"全失败时不应调用 aggregator provider,实际调用: {aggregator_called}"
|
f"全失败时不应调用 aggregator provider,实际调用: {aggregator_called}"
|
||||||
# 断言:model 使用 settings 默认值
|
# P0-03:degraded 路径 status=degraded(model 可能为 None,由 aggregator 降级逻辑决定)
|
||||||
assert captured_values.get("model") is not None
|
|
||||||
assert captured_values.get("status") == "degraded"
|
assert captured_values.get("status") == "degraded"
|
||||||
print(f"PASS: 全失败 → aggregator provider 未调用,model={captured_values.get('model')}")
|
print(f"PASS: 全失败 → aggregator provider 未调用,status={captured_values.get('status')}")
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"""P0-03 核心测试: Prediction 幂等指纹。
|
||||||
|
|
||||||
|
- TestFingerprintLogic:用 mock session 验证同/不同 fingerprint 的 INSERT/返回逻辑(无 PG 依赖)。
|
||||||
|
- TestFingerprintDeterminism:纯 hash 稳定性(无 PG 依赖)。
|
||||||
|
|
||||||
|
运行: pytest tests/test_p0_prediction_fingerprint.py -v
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.db.models import Prediction
|
||||||
|
from src.llm.predict import _compute_fingerprint, _insert_or_find_by_fingerprint
|
||||||
|
|
||||||
|
|
||||||
|
def _base_values(match_id, **overrides):
|
||||||
|
base = {
|
||||||
|
"match_id": match_id,
|
||||||
|
"provider": "test-provider",
|
||||||
|
"model": "test-model",
|
||||||
|
"mode": "single",
|
||||||
|
"run_type": "live",
|
||||||
|
"prompt_version": "v1",
|
||||||
|
"prompt_hash": "ph1",
|
||||||
|
"system_prompt_hash": "sh1",
|
||||||
|
"temperature": 0.3,
|
||||||
|
"context_hash": "ch1",
|
||||||
|
"agent_ids": [],
|
||||||
|
"prediction_cutoff_at": "2026-01-01T14:00:00+00:00",
|
||||||
|
}
|
||||||
|
base.update(overrides)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeSession:
|
||||||
|
"""模拟 session:记录 add;execute 返回预设的 existing row。"""
|
||||||
|
|
||||||
|
def __init__(self, existing=None):
|
||||||
|
self._existing = existing
|
||||||
|
self.added: list = []
|
||||||
|
self.flushed = 0
|
||||||
|
|
||||||
|
def add(self, obj):
|
||||||
|
self.added.append(obj)
|
||||||
|
|
||||||
|
async def execute(self, stmt):
|
||||||
|
existing = self._existing
|
||||||
|
|
||||||
|
class _R:
|
||||||
|
def scalar_one_or_none(inner_self):
|
||||||
|
return existing
|
||||||
|
|
||||||
|
return _R()
|
||||||
|
|
||||||
|
async def flush(self):
|
||||||
|
self.flushed += 1
|
||||||
|
|
||||||
|
async def refresh(self, obj):
|
||||||
|
if getattr(obj, "id", None) is None:
|
||||||
|
obj.id = 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestFingerprintLogic:
|
||||||
|
"""P0-03:同 fingerprint 返回已有行(不 UPDATE/INSERT);不同 → INSERT。"""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_same_fingerprint_returns_existing_without_update(self):
|
||||||
|
# 构造一个"已存在"的行
|
||||||
|
existing = Prediction(
|
||||||
|
id=42, match_id=1, provider="test-provider", model="test-model",
|
||||||
|
prompt_version="v1", input_hash="same-hash",
|
||||||
|
)
|
||||||
|
existing.pred_home_goals = 2.0
|
||||||
|
existing.prompt_version = "v1"
|
||||||
|
|
||||||
|
s = _FakeSession(existing=existing)
|
||||||
|
values = _base_values(1, prompt_version="v1") # 与 existing 同 fingerprint 需 input_hash 相同
|
||||||
|
|
||||||
|
# 但 fingerprint 是动态计算的,existing.input_hash 需匹配。直接让 fake 返回 existing。
|
||||||
|
result = await _insert_or_find_by_fingerprint(s, values=values)
|
||||||
|
|
||||||
|
# 应返回 existing,不 add 新行
|
||||||
|
assert result is existing, "同 fingerprint 必须返回已有行"
|
||||||
|
assert s.added == [], "同 fingerprint 不应 INSERT"
|
||||||
|
assert result.pred_home_goals == 2.0, "返回的应是已有行(字段不变)"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_different_fingerprint_inserts_new(self):
|
||||||
|
# 无已有行 → INSERT
|
||||||
|
s = _FakeSession(existing=None)
|
||||||
|
values = _base_values(1, prompt_version="v1", context_hash="ch1")
|
||||||
|
|
||||||
|
result = await _insert_or_find_by_fingerprint(s, values=values)
|
||||||
|
|
||||||
|
assert len(s.added) == 1, "无已有行时应 INSERT"
|
||||||
|
assert isinstance(s.added[0], Prediction)
|
||||||
|
# input_hash 应被设为指纹
|
||||||
|
assert result.input_hash is not None and len(result.input_hash) == 64 # SHA-256 hex
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fingerprint_computed_from_values(self):
|
||||||
|
"""fingerprint 应基于 values 的全部关键字段计算。"""
|
||||||
|
s1 = _FakeSession(existing=None)
|
||||||
|
s2 = _FakeSession(existing=None)
|
||||||
|
|
||||||
|
v1 = _base_values(1, prompt_version="v1")
|
||||||
|
v2 = _base_values(1, prompt_version="v1") # 同值
|
||||||
|
|
||||||
|
r1 = await _insert_or_find_by_fingerprint(s1, values=v1)
|
||||||
|
r2 = await _insert_or_find_by_fingerprint(s2, values=v2)
|
||||||
|
|
||||||
|
# 同值 → 同 fingerprint(跨 session 也一致)
|
||||||
|
assert r1.input_hash == r2.input_hash
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_existing_never_updated(self):
|
||||||
|
"""核心可信度:同 fingerprint 绝不覆盖 pred_/reasoning/agent_outputs。"""
|
||||||
|
existing = Prediction(
|
||||||
|
id=99, match_id=1, provider="p", model="m",
|
||||||
|
prompt_version="v1", input_hash="fixed-hash",
|
||||||
|
pred_home_goals=1.0, pred_away_goals=0.0,
|
||||||
|
reasoning="original", agent_outputs=[{"agent": "form"}],
|
||||||
|
)
|
||||||
|
s = _FakeSession(existing=existing)
|
||||||
|
|
||||||
|
# 即便传入不同的 pred_*,也应返回原行(字段不变)
|
||||||
|
values = _base_values(1, prompt_version="v1")
|
||||||
|
# 让 fake 返回 existing: 需 fingerprint 匹配。fake.execute 始终返回 existing。
|
||||||
|
result = await _insert_or_find_by_fingerprint(s, values=values)
|
||||||
|
|
||||||
|
assert result is existing
|
||||||
|
assert result.pred_home_goals == 1.0, "pred_home_goals 不应被覆盖"
|
||||||
|
assert result.reasoning == "original", "reasoning 不应被覆盖"
|
||||||
|
assert result.agent_outputs == [{"agent": "form"}], "agent_outputs 不应被覆盖"
|
||||||
|
|
||||||
|
|
||||||
|
class TestFingerprintDeterminism:
|
||||||
|
"""fingerprint 必须稳定(同输入 → 同 hash)。"""
|
||||||
|
|
||||||
|
def test_same_values_same_fingerprint(self):
|
||||||
|
v = _base_values(1)
|
||||||
|
assert _compute_fingerprint(v) == _compute_fingerprint(dict(v))
|
||||||
|
|
||||||
|
def test_different_prompt_version_different_fingerprint(self):
|
||||||
|
v1 = _base_values(1, prompt_version="v1")
|
||||||
|
v2 = _base_values(1, prompt_version="v2")
|
||||||
|
assert _compute_fingerprint(v1) != _compute_fingerprint(v2)
|
||||||
|
|
||||||
|
def test_different_agent_ids_different_fingerprint(self):
|
||||||
|
v1 = _base_values(1, agent_ids=["form", "stats"])
|
||||||
|
v2 = _base_values(1, agent_ids=["form", "h2h"])
|
||||||
|
assert _compute_fingerprint(v1) != _compute_fingerprint(v2)
|
||||||
|
|
||||||
|
def test_different_cutoff_different_fingerprint(self):
|
||||||
|
v1 = _base_values(1, prediction_cutoff_at="2026-01-01T14:00:00+00:00")
|
||||||
|
v2 = _base_values(1, prediction_cutoff_at="2026-01-01T10:00:00+00:00")
|
||||||
|
assert _compute_fingerprint(v1) != _compute_fingerprint(v2)
|
||||||
|
|
||||||
|
def test_different_context_different_fingerprint(self):
|
||||||
|
v1 = _base_values(1, context_hash="ch1")
|
||||||
|
v2 = _base_values(1, context_hash="ch2")
|
||||||
|
assert _compute_fingerprint(v1) != _compute_fingerprint(v2)
|
||||||
|
|
||||||
|
def test_agent_ids_order_independent(self):
|
||||||
|
"""agent_ids 排序后计算,顺序不影响 hash。"""
|
||||||
|
v1 = _base_values(1, agent_ids=["stats", "form"])
|
||||||
|
v2 = _base_values(1, agent_ids=["form", "stats"])
|
||||||
|
assert _compute_fingerprint(v1) == _compute_fingerprint(v2)
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"""P0-01 回归测试: missing score 不得变 0:0。
|
||||||
|
|
||||||
|
运行: pytest tests/test_p0_score_status.py -v
|
||||||
|
(无需真实 PG;用 fake DB + 模型元数据断言。)
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.db.models import League, Match, Team
|
||||||
|
|
||||||
|
|
||||||
|
# ── fake DB(对齐现有测试约定) ────────────────────────────────────
|
||||||
|
class _FakeResult:
|
||||||
|
def __init__(self, items): self._items = list(items)
|
||||||
|
def scalars(self): return self
|
||||||
|
def all(self): return list(self._items)
|
||||||
|
def scalar_one_or_none(self): return self._items[0] if self._items else None
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeDB:
|
||||||
|
def __init__(self): self.added = []
|
||||||
|
def add(self, obj): self.added.append(obj)
|
||||||
|
async def execute(self, stmt): return _FakeResult([])
|
||||||
|
async def flush(self):
|
||||||
|
for o in self.added:
|
||||||
|
if getattr(o, "id", None) is None:
|
||||||
|
o.id = 1
|
||||||
|
|
||||||
|
|
||||||
|
def _league(lid=1):
|
||||||
|
lg = League(id=lid, code="E0", name="Test", country="X")
|
||||||
|
return lg
|
||||||
|
|
||||||
|
|
||||||
|
def _teams():
|
||||||
|
return Team(id=10, name="Arsenal FC", name_zh="阿森纳"), Team(id=20, name="Chelsea FC", name_zh="切尔西")
|
||||||
|
|
||||||
|
|
||||||
|
class TestScoreStatusConstraintPresence:
|
||||||
|
"""模型必须定义 score_status 相关 CHECK 约束。"""
|
||||||
|
|
||||||
|
def test_score_status_column_exists(self):
|
||||||
|
cols = {c.name for c in Match.__table__.columns}
|
||||||
|
assert "score_status" in cols
|
||||||
|
|
||||||
|
def test_score_integrity_check_exists(self):
|
||||||
|
names = {c.name for c in Match.__table__.constraints if c.name}
|
||||||
|
# 新约束 ck_matches_score_integrity 必须存在
|
||||||
|
assert any("score_integrity" in n for n in names), \
|
||||||
|
f"ck_matches_score_integrity 未找到,现有约束: {names}"
|
||||||
|
|
||||||
|
def test_old_finished_has_score_check_removed(self):
|
||||||
|
names = {c.name for c in Match.__table__.constraints}
|
||||||
|
assert "ck_matches_finished_has_score" not in names, \
|
||||||
|
"旧约束 ck_matches_finished_has_score 应已被替换"
|
||||||
|
|
||||||
|
|
||||||
|
class TestMatchAcceptsMissingScore:
|
||||||
|
"""Match 对象层面: 完赛 + score_status=missing + goals=NULL 必须可构造。"""
|
||||||
|
|
||||||
|
def test_construct_finished_missing_null_goals(self):
|
||||||
|
home, away = _teams()
|
||||||
|
m = Match(
|
||||||
|
id=1, league_id=_league().id, home_team_id=home.id, away_team_id=away.id,
|
||||||
|
match_date="2026-01-01 15:00:00+00:00",
|
||||||
|
match_status="finished", score_status="missing",
|
||||||
|
home_goals=None, away_goals=None,
|
||||||
|
)
|
||||||
|
assert m.home_goals is None
|
||||||
|
assert m.away_goals is None
|
||||||
|
assert m.score_status == "missing"
|
||||||
|
|
||||||
|
def test_add_to_fake_db(self):
|
||||||
|
db = _FakeDB()
|
||||||
|
home, away = _teams()
|
||||||
|
m = Match(
|
||||||
|
league_id=_league().id, home_team_id=home.id, away_team_id=away.id,
|
||||||
|
match_date="2026-01-01 15:00:00+00:00",
|
||||||
|
match_status="finished", score_status="missing",
|
||||||
|
home_goals=None, away_goals=None,
|
||||||
|
)
|
||||||
|
db.add(m)
|
||||||
|
|
||||||
|
def test_server_default_is_unknown(self):
|
||||||
|
"""score_status 列的 server_default 必须为 unknown(DB 插入未显式赋值时兜底)。"""
|
||||||
|
col = Match.__table__.c.score_status
|
||||||
|
assert col.server_default is not None
|
||||||
|
assert "unknown" in str(col.server_default.arg)
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizeNoDowngrade:
|
||||||
|
"""normalize_bzzoiro 不得把完赛缺分静默降级为 scheduled。"""
|
||||||
|
|
||||||
|
def _raw(self, status="finished", home_score=None, away_score=None):
|
||||||
|
return {
|
||||||
|
"event_date": "2026-01-01 15:00:00",
|
||||||
|
"status": status,
|
||||||
|
"home_team": "Arsenal",
|
||||||
|
"away_team": "Chelsea",
|
||||||
|
"home_score": home_score,
|
||||||
|
"away_score": away_score,
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_finished_missing_score_keeps_finished(self):
|
||||||
|
from src.data.normalize import normalize_bzzoiro
|
||||||
|
m = normalize_bzzoiro(self._raw("finished", None, None), "E0")
|
||||||
|
assert m is not None
|
||||||
|
assert m.match_status == "finished", "完赛缺分不得降级为 scheduled"
|
||||||
|
assert m.score_status == "missing"
|
||||||
|
assert m.home_goals is None
|
||||||
|
assert m.away_goals is None
|
||||||
|
|
||||||
|
def test_finished_with_score_is_known(self):
|
||||||
|
from src.data.normalize import normalize_bzzoiro
|
||||||
|
m = normalize_bzzoiro(self._raw("finished", 2, 1), "E0")
|
||||||
|
assert m.match_status == "finished"
|
||||||
|
assert m.score_status == "known"
|
||||||
|
assert m.home_goals == 2 and m.away_goals == 1
|
||||||
|
|
||||||
|
def test_scheduled_no_score_is_unknown(self):
|
||||||
|
from src.data.normalize import normalize_bzzoiro
|
||||||
|
m = normalize_bzzoiro(self._raw("scheduled", None, None), "E0")
|
||||||
|
assert m.match_status == "scheduled"
|
||||||
|
assert m.score_status == "unknown"
|
||||||
|
assert m.home_goals is None
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"""P0-02 回归测试: 积分榜改为追加快照(append-only) + available_at cutoff。
|
||||||
|
|
||||||
|
运行: pytest tests/test_p0_standings_cutoff.py -v
|
||||||
|
(模型约束用 fake DB;cutoff 过滤语义用 fake session 验证参数传递。)
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from src.db.models import League, Standing, Team
|
||||||
|
|
||||||
|
|
||||||
|
# ── fake DB(对齐现有测试约定) ────────────────────────────────────
|
||||||
|
class _FakeResult:
|
||||||
|
def __init__(self, items): self._items = list(items)
|
||||||
|
def scalars(self):
|
||||||
|
class _S:
|
||||||
|
def __init__(self, items): self._items = items
|
||||||
|
def all(self): return list(self._items)
|
||||||
|
return _S(self._items)
|
||||||
|
def scalar_one_or_none(self):
|
||||||
|
return self._items[0] if self._items else None
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeDB:
|
||||||
|
captured: list = []
|
||||||
|
|
||||||
|
def __init__(self, league=None, standing_rows=None):
|
||||||
|
self._league = league
|
||||||
|
self._rows = standing_rows or []
|
||||||
|
_FakeDB.captured = []
|
||||||
|
|
||||||
|
def add(self, obj):
|
||||||
|
_FakeDB.captured.append(obj)
|
||||||
|
|
||||||
|
async def execute(self, stmt):
|
||||||
|
# 记录生成的 SQL(字符串化)供断言
|
||||||
|
_FakeDB.captured.append(str(stmt))
|
||||||
|
compiled = str(stmt)
|
||||||
|
if "league" in compiled.lower() and "standing" not in compiled.lower():
|
||||||
|
return _FakeResult([self._league] if self._league else [])
|
||||||
|
return _FakeResult(self._rows)
|
||||||
|
|
||||||
|
async def flush(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeTeam:
|
||||||
|
def __init__(self, tid, name):
|
||||||
|
self.id = tid
|
||||||
|
self.name = name
|
||||||
|
self.name_zh = None
|
||||||
|
|
||||||
|
|
||||||
|
class _Header:
|
||||||
|
def __init__(self):
|
||||||
|
from src.llm.slices.common import MatchHeader
|
||||||
|
self._h = MatchHeader(
|
||||||
|
match_id=1, home_name="A", away_name="B", league_name="E0",
|
||||||
|
season="2026", match_date="2026-01-01", match_dt=None,
|
||||||
|
stage=None, home_team_id=10, away_team_id=20, league_id=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __getattr__(self, name):
|
||||||
|
return getattr(self._h, name)
|
||||||
|
|
||||||
|
|
||||||
|
class TestStandingsModel:
|
||||||
|
"""standings 模型必须有 available_at + 新唯一约束。"""
|
||||||
|
|
||||||
|
def test_available_at_column(self):
|
||||||
|
cols = {c.name for c in Standing.__table__.columns}
|
||||||
|
assert "available_at" in cols
|
||||||
|
|
||||||
|
def test_unique_constraint_includes_available_at(self):
|
||||||
|
names = {c.name for c in Standing.__table__.constraints}
|
||||||
|
# 匿名约束(PK/Check)的 name 是 None,必须先判真值再子串匹配,
|
||||||
|
# 否则 any() 撞上 None 直接 TypeError(集合顺序不定 → flaky)
|
||||||
|
assert any(n and "available" in n and n.startswith("uq_") for n in names), \
|
||||||
|
f"缺少含 available_at 的唯一约束,现有: {names}"
|
||||||
|
|
||||||
|
def test_old_unique_constraint_removed(self):
|
||||||
|
names = {c.name for c in Standing.__table__.constraints}
|
||||||
|
assert "uq_standings_league_season_team" not in names, \
|
||||||
|
"旧约束 uq_standings_league_season_team 应已被替换"
|
||||||
|
|
||||||
|
|
||||||
|
class TestStandingsSliceCutoff:
|
||||||
|
"""standings_slice 必须尊重 before(cutoff):before=None → now()。"""
|
||||||
|
|
||||||
|
def test_before_none_uses_now(self):
|
||||||
|
"""before=None 时应将 cutoff 视为 now()(取最新可用快照)。"""
|
||||||
|
from src.llm.slices import standings as st_mod
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
calls = {}
|
||||||
|
real_execute = None
|
||||||
|
|
||||||
|
class _DB:
|
||||||
|
def __init__(self): self._league = League(id=1, code="E0", name="E0")
|
||||||
|
def add(self, obj): pass
|
||||||
|
async def execute(self, stmt):
|
||||||
|
# 捕获 WHERE available_at <= ? 的参数
|
||||||
|
sql = str(stmt)
|
||||||
|
if "available_at" in sql:
|
||||||
|
# 提取编译后的 params
|
||||||
|
try:
|
||||||
|
params = stmt.compile().params
|
||||||
|
calls["cutoff"] = params.get("available_at_1")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if "league" in sql.lower() and "standing" not in sql.lower():
|
||||||
|
return _FakeResult([self._league])
|
||||||
|
return _FakeResult([])
|
||||||
|
async def flush(self): pass
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
db = _DB()
|
||||||
|
before = None
|
||||||
|
await st_mod.standings_slice(_Header(), before=before, db=db)
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
asyncio.run(run())
|
||||||
|
# before=None 时应注入 now() 作为 cutoff
|
||||||
|
assert "cutoff" in calls, "未对 available_at 施加 cutoff 过滤"
|
||||||
|
assert calls["cutoff"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
class TestStandingsAppendOnly:
|
||||||
|
"""采集应 INSERT 新行(带 available_at),不覆盖旧行。"""
|
||||||
|
|
||||||
|
def test_values_include_available_at(self):
|
||||||
|
"""采集构造的 Standing 必须含 available_at 字段。"""
|
||||||
|
from src.data import bzzoiro_standings as bzs
|
||||||
|
# 检查函数源码是否包含 available_at(编译期守卫)
|
||||||
|
import inspect
|
||||||
|
src = inspect.getsource(bzs.ingest_bzzoiro_standings)
|
||||||
|
assert "available_at" in src, "采集函数必须设置 available_at"
|
||||||
|
# 不应再出现按 (league, season, team) 的 upsert 查询
|
||||||
|
assert "scalar_one_or_none" not in src or "Standing.league_id == league.id" not in src.replace("available_at", ""), \
|
||||||
|
"不应再按 (league, season, team) 做 upsert 查询"
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""P1-A 回归测试: ingest 联赛级 inserted/updated 必须读 leagues[code],而非顶层 r.get("inserted")。
|
||||||
|
|
||||||
|
运行: pytest tests/test_p1_a_ingest_league_counts.py -v
|
||||||
|
(纯函数测试,无 DB/网络依赖。)
|
||||||
|
"""
|
||||||
|
from src.api.routes.ingest import _accumulate_ingest_result
|
||||||
|
|
||||||
|
|
||||||
|
class TestAccumulateIngestResult:
|
||||||
|
"""P1-A: _accumulate_ingest_result 联赛级计数必须来自 r["leagues"][code]。"""
|
||||||
|
|
||||||
|
def _merged(self):
|
||||||
|
return {"leagues": {}, "total_inserted": 0, "total_updated": 0, "errors": []}
|
||||||
|
|
||||||
|
def test_league_counts_read_from_leagues_key(self):
|
||||||
|
"""核心: 联赛级 inserted/updated 应来自 leagues[code],而非顶层 inserted/updated。"""
|
||||||
|
merged = self._merged()
|
||||||
|
r = {
|
||||||
|
# 顶层无 inserted/updated 键(只有 total_*)
|
||||||
|
"total_inserted": 5,
|
||||||
|
"total_updated": 2,
|
||||||
|
"errors": [],
|
||||||
|
"leagues": {"E0": {"inserted": 3, "updated": 1, "rows": 4, "errors": []}},
|
||||||
|
}
|
||||||
|
_accumulate_ingest_result(merged, "E0", r)
|
||||||
|
|
||||||
|
# 顶层总计
|
||||||
|
assert merged["total_inserted"] == 5
|
||||||
|
assert merged["total_updated"] == 2
|
||||||
|
# 联赛级计数来自 leagues["E0"],而非顶层
|
||||||
|
assert merged["leagues"]["E0"]["inserted"] == 3, "联赛 inserted 必须来自 leagues[code]"
|
||||||
|
assert merged["leagues"]["E0"]["updated"] == 1, "联赛 updated 必须来自 leagues[code]"
|
||||||
|
|
||||||
|
def test_does_not_read_top_level_inserted(self):
|
||||||
|
"""防御: 若 r 误含顶层 inserted 键,不得影响联赛级计数。"""
|
||||||
|
merged = self._merged()
|
||||||
|
r = {
|
||||||
|
"total_inserted": 5,
|
||||||
|
"total_updated": 2,
|
||||||
|
"inserted": 999, # 错误的顶层键(旧代码可能读这个)
|
||||||
|
"updated": 999,
|
||||||
|
"errors": [],
|
||||||
|
"leagues": {"E0": {"inserted": 3, "updated": 1}},
|
||||||
|
}
|
||||||
|
_accumulate_ingest_result(merged, "E0", r)
|
||||||
|
# 必须忽略顶层 inserted/updated,使用 leagues["E0"]
|
||||||
|
assert merged["leagues"]["E0"]["inserted"] == 3
|
||||||
|
assert merged["leagues"]["E0"]["updated"] == 1
|
||||||
|
|
||||||
|
def test_missing_league_key_defaults_to_zero(self):
|
||||||
|
"""r["leagues"] 无该 code 时,默认 0 不抛错。"""
|
||||||
|
merged = self._merged()
|
||||||
|
r = {"total_inserted": 1, "total_updated": 0, "errors": [], "leagues": {}}
|
||||||
|
_accumulate_ingest_result(merged, "E0", r)
|
||||||
|
assert merged["leagues"]["E0"]["inserted"] == 0
|
||||||
|
assert merged["total_inserted"] == 1
|
||||||
|
|
||||||
|
def test_multiple_calls_accumulate(self):
|
||||||
|
"""多次调用应累加到同一联赛。"""
|
||||||
|
merged = self._merged()
|
||||||
|
r1 = {"total_inserted": 3, "total_updated": 1, "errors": [], "leagues": {"E0": {"inserted": 3, "updated": 1}}}
|
||||||
|
r2 = {"total_inserted": 2, "total_updated": 0, "errors": [], "leagues": {"E0": {"inserted": 2, "updated": 0}}}
|
||||||
|
_accumulate_ingest_result(merged, "E0", r1)
|
||||||
|
_accumulate_ingest_result(merged, "E0", r2)
|
||||||
|
assert merged["leagues"]["E0"]["inserted"] == 5
|
||||||
|
assert merged["leagues"]["E0"]["updated"] == 1
|
||||||
|
assert merged["total_inserted"] == 5
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""P1-B 回归测试: 非法 cursor → 400 + code=INVALID_CURSOR。
|
||||||
|
|
||||||
|
运行: pytest tests/test_p1_b_invalid_cursor.py -v
|
||||||
|
(_parse_cursor 为纯函数,无 DB/网络依赖;HTTP 层仅测非法格式。)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from src.api.app import app
|
||||||
|
from src.api.deps import require_admin
|
||||||
|
from src.api.routes.matches import _parse_cursor
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseCursorPure:
|
||||||
|
"""P1-B 纯函数:_parse_cursor 解析与非法校验。"""
|
||||||
|
|
||||||
|
def test_valid_cursor(self):
|
||||||
|
d, mid = _parse_cursor("2026-01-01T15:00:00+00:00|42")
|
||||||
|
assert d == datetime(2026, 1, 1, 15, 0, tzinfo=timezone.utc)
|
||||||
|
assert mid == 42
|
||||||
|
|
||||||
|
def test_missing_pipe_raises_400(self):
|
||||||
|
with pytest.raises(HTTPException) as ei:
|
||||||
|
_parse_cursor("no-pipe-here")
|
||||||
|
assert ei.value.status_code == 400
|
||||||
|
assert ei.value.detail["code"] == "INVALID_CURSOR"
|
||||||
|
|
||||||
|
def test_empty_date_raises_400(self):
|
||||||
|
with pytest.raises(HTTPException) as ei:
|
||||||
|
_parse_cursor("|5")
|
||||||
|
assert ei.value.status_code == 400
|
||||||
|
assert ei.value.detail["code"] == "INVALID_CURSOR"
|
||||||
|
|
||||||
|
def test_non_numeric_id_raises_400(self):
|
||||||
|
with pytest.raises(HTTPException) as ei:
|
||||||
|
_parse_cursor("2026-01-01T00:00:00+00:00|abc")
|
||||||
|
assert ei.value.status_code == 400
|
||||||
|
assert ei.value.detail["code"] == "INVALID_CURSOR"
|
||||||
|
|
||||||
|
def test_invalid_date_raises_400(self):
|
||||||
|
with pytest.raises(HTTPException) as ei:
|
||||||
|
_parse_cursor("not-a-date|1")
|
||||||
|
assert ei.value.status_code == 400
|
||||||
|
assert ei.value.detail["code"] == "INVALID_CURSOR"
|
||||||
|
|
||||||
|
def test_extra_pipe_raises_400(self):
|
||||||
|
"""含额外 | 时 id 部分为 "42|extra",int() 失败 → 400。"""
|
||||||
|
with pytest.raises(HTTPException) as ei:
|
||||||
|
_parse_cursor("2026-01-01T15:00:00+00:00|42|extra")
|
||||||
|
assert ei.value.status_code == 400
|
||||||
|
assert ei.value.detail["code"] == "INVALID_CURSOR"
|
||||||
|
|
||||||
|
|
||||||
|
class TestInvalidCursorHTTP:
|
||||||
|
"""P1-B HTTP 层:非法 cursor → 400 + code=INVALID_CURSOR。"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(self):
|
||||||
|
app.dependency_overrides[require_admin] = lambda: None
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
def test_malformed_cursor_400(self, client):
|
||||||
|
resp = client.get("/api/v1/matches?cursor=garbage")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert resp.json()["detail"]["code"] == "INVALID_CURSOR"
|
||||||
|
|
||||||
|
def test_missing_id_400(self, client):
|
||||||
|
resp = client.get("/api/v1/matches?cursor=2026-01-01T00:00:00|")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert resp.json()["detail"]["code"] == "INVALID_CURSOR"
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"""P1-C 回归测试: 公开预测仅 live+success,且不含 reasoning/agent_outputs。
|
||||||
|
|
||||||
|
运行: pytest tests/test_p1_c_public_predictions.py -v
|
||||||
|
(使用 fake DB,无需真实 PG。)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from src.api.app import app
|
||||||
|
from src.api.deps import require_admin
|
||||||
|
from src.db.models import League, Match, MatchStats, Prediction, Team
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResult:
|
||||||
|
def __init__(self, items): self._items = list(items)
|
||||||
|
def scalars(self):
|
||||||
|
class _S:
|
||||||
|
def __init__(self, items): self._items = items
|
||||||
|
def all(self): return list(self._items)
|
||||||
|
return _S(self._items)
|
||||||
|
def scalar_one_or_none(self):
|
||||||
|
return self._items[0] if self._items else None
|
||||||
|
def scalar(self):
|
||||||
|
return self._items[0] if self._items else None
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeDB:
|
||||||
|
"""假 DB:捕获发往 Prediction 的查询语句,供测试断言 SQL 过滤条件。"""
|
||||||
|
|
||||||
|
captured_pred_stmts: list = []
|
||||||
|
|
||||||
|
def __init__(self, match=None, predictions=()):
|
||||||
|
self._match = match
|
||||||
|
self._predictions = list(predictions)
|
||||||
|
|
||||||
|
async def execute(self, stmt):
|
||||||
|
# 根据 column_descriptions 判断查询实体
|
||||||
|
try:
|
||||||
|
entity = stmt.column_descriptions[0]["entity"]
|
||||||
|
except (IndexError, KeyError):
|
||||||
|
entity = None
|
||||||
|
if entity is Prediction:
|
||||||
|
_FakeDB.captured_pred_stmts.append(stmt)
|
||||||
|
return _FakeResult(self._predictions)
|
||||||
|
return _FakeResult([self._match] if self._match else [])
|
||||||
|
|
||||||
|
async def get(self, cls, mid):
|
||||||
|
return self._match
|
||||||
|
|
||||||
|
|
||||||
|
def _make_match(mid=1):
|
||||||
|
home = Team(id=10, name="Arsenal", name_zh="阿森纳")
|
||||||
|
away = Team(id=20, name="Chelsea", name_zh="切尔西")
|
||||||
|
lg = League(id=1, code="E0", name="Premier", country="EN")
|
||||||
|
m = Match(
|
||||||
|
id=mid, league_id=1, home_team_id=10, away_team_id=20,
|
||||||
|
match_date=datetime(2026, 1, 1, 15, 0, tzinfo=timezone.utc),
|
||||||
|
match_status="finished",
|
||||||
|
)
|
||||||
|
m.league = lg
|
||||||
|
m.home_team = home
|
||||||
|
m.away_team = away
|
||||||
|
m.stats = MatchStats(match_id=mid, home_xg=1.5, away_xg=1.0)
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
def _make_pred(pid, match_id, run_type="live", status="success", **overrides):
|
||||||
|
p = Prediction(
|
||||||
|
id=pid, match_id=match_id, provider="openai", model="gpt-4o",
|
||||||
|
prompt_version="v1", mode=run_type, run_type=run_type, status=status,
|
||||||
|
pred_home_goals=2.0, pred_away_goals=1.0, pred_1x2="1",
|
||||||
|
reasoning="内部推理细节", agent_outputs=[{"agent": "form"}],
|
||||||
|
subjective_confidence=0.7,
|
||||||
|
created_at=datetime(2026, 1, 2, 12, 0, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
for k, v in overrides.items():
|
||||||
|
setattr(p, k, v)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
from src.db.base import get_db_read
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
app.dependency_overrides[require_admin] = lambda: None
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPublicPredictionsFilter:
|
||||||
|
"""P1-C: GET /matches/{id} 公开预测仅 run_type=live 且 status=success。"""
|
||||||
|
|
||||||
|
def test_query_filters_by_run_type_and_status(self, client):
|
||||||
|
"""P1-C: 查询必须包含 run_type='live' AND status='success' 过滤。"""
|
||||||
|
_FakeDB.captured_pred_stmts = []
|
||||||
|
m = _make_match(1)
|
||||||
|
fake = _FakeDB(match=m, predictions=[_make_pred(1, 1)])
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db_read] = lambda: fake
|
||||||
|
try:
|
||||||
|
resp = client.get("/api/v1/matches/1")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
# 验证发往 Prediction 的 SQL 含 run_type 与 status 过滤
|
||||||
|
assert _FakeDB.captured_pred_stmts, "未发出 Prediction 查询"
|
||||||
|
sql = str(_FakeDB.captured_pred_stmts[0]).lower()
|
||||||
|
assert "run_type" in sql, f"SQL 缺少 run_type 过滤: {sql}"
|
||||||
|
assert "status" in sql, f"SQL 缺少 status 过滤: {sql}"
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.pop(get_db_read, None)
|
||||||
|
|
||||||
|
def test_no_reasoning_or_agent_outputs(self, client):
|
||||||
|
"""P1-C: 公开预测不得含 reasoning/agent_outputs。"""
|
||||||
|
m = _make_match(1)
|
||||||
|
preds = [_make_pred(1, 1, run_type="live", status="success")]
|
||||||
|
fake = _FakeDB(match=m, predictions=preds)
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db_read] = lambda: fake
|
||||||
|
try:
|
||||||
|
resp = client.get("/api/v1/matches/1")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert len(body["recent_predictions"]) == 1
|
||||||
|
p = body["recent_predictions"][0]
|
||||||
|
assert "reasoning" not in p, "公开预测不得含 reasoning"
|
||||||
|
assert "agent_outputs" not in p, "公开预测不得含 agent_outputs"
|
||||||
|
# 但核心字段保留
|
||||||
|
assert p["pred_home_goals"] == 2.0
|
||||||
|
assert p["pred_1x2"] == "1"
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.pop(get_db_read, None)
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""P1-D 回归测试: 全局 LLM 并发限制 + provider 字段已删除。
|
||||||
|
|
||||||
|
运行: pytest tests/test_p1_d_concurrency.py -v
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.api.routes import predict as predict_mod
|
||||||
|
|
||||||
|
|
||||||
|
class TestGlobalLLMConcurrency:
|
||||||
|
"""P1-D: 全局 LLM 并发限制(默认 4)。"""
|
||||||
|
|
||||||
|
def test_semaphore_exists_with_limit(self):
|
||||||
|
"""路由模块必须存在 _GLOBAL_LLM_SEMAPHORE 且 value <= 4。"""
|
||||||
|
assert hasattr(predict_mod, "_GLOBAL_LLM_SEMAPHORE")
|
||||||
|
sem = predict_mod._GLOBAL_LLM_SEMAPHORE
|
||||||
|
assert isinstance(sem, asyncio.Semaphore)
|
||||||
|
assert sem._value == 4, f"期望并发限制 4,实际 {sem._value}"
|
||||||
|
|
||||||
|
def test_predict_with_concurrency_limits_parallel(self):
|
||||||
|
"""P1-D: 并发调用 _predict_with_concurrency 不得超过信号量限制。"""
|
||||||
|
max_concurrent = 0
|
||||||
|
current = 0
|
||||||
|
lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def fake_predict(match_id, **kwargs):
|
||||||
|
nonlocal current, max_concurrent
|
||||||
|
async with lock:
|
||||||
|
current += 1
|
||||||
|
max_concurrent = max(max_concurrent, current)
|
||||||
|
await asyncio.sleep(0.05) # 模拟 LLM 调用
|
||||||
|
async with lock:
|
||||||
|
current -= 1
|
||||||
|
return type("R", (), {"prediction_id": 1, "provider": "p", "model": "m",
|
||||||
|
"prompt_version": "v1", "pred_home_goals": 1.0,
|
||||||
|
"pred_away_goals": 0.0, "pred_1x2": "1",
|
||||||
|
"subjective_confidence": 0.5, "reasoning": "",
|
||||||
|
"status": "success", "context": "",
|
||||||
|
"latency_ms": 0, "raw": {}})()
|
||||||
|
|
||||||
|
req = type("Req", (), {"match_id": 1, "model": None, "prompt_version": None, "mode": "single"})()
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
with patch.object(predict_mod, "predict_match", fake_predict):
|
||||||
|
# 启动 10 个并发请求
|
||||||
|
tasks = [predict_mod._predict_with_concurrency(req) for _ in range(10)]
|
||||||
|
await asyncio.gather(*tasks)
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
# 最大并发不得超过信号量限制(4)
|
||||||
|
assert max_concurrent <= 4, f"并发 {max_concurrent} 超过限制 4"
|
||||||
|
|
||||||
|
|
||||||
|
class TestProviderFieldRemoved:
|
||||||
|
"""P1-D: PredictRequest 的 provider 字段必须已删除(未接线)。"""
|
||||||
|
|
||||||
|
def test_predict_request_no_provider(self):
|
||||||
|
from src.api.schemas import PredictRequest
|
||||||
|
|
||||||
|
fields = set(PredictRequest.model_fields.keys())
|
||||||
|
assert "provider" not in fields, f"PredictRequest 应已删除 provider 字段,现有: {fields}"
|
||||||
|
|
||||||
|
def test_predict_request_still_has_core_fields(self):
|
||||||
|
from src.api.schemas import PredictRequest
|
||||||
|
|
||||||
|
fields = set(PredictRequest.model_fields.keys())
|
||||||
|
for required in ("match_id", "model", "prompt_version", "mode"):
|
||||||
|
assert required in fields, f"缺少核心字段 {required}"
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""P1-E 回归测试: 启动时将过期 pending/running ingest_jobs 标 failed。
|
||||||
|
|
||||||
|
运行: pytest tests/test_p1_e_stale_ingest_jobs.py -v
|
||||||
|
(使用 mock session,验证 UPDATE 过滤条件与 SET 值。)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import src.api.app as app_mod
|
||||||
|
|
||||||
|
|
||||||
|
def _make_fake_session(commit_out: dict):
|
||||||
|
session = MagicMock()
|
||||||
|
session.__aenter__ = MagicMock(return_value=session)
|
||||||
|
session.__aexit__ = MagicMock(return_value=False)
|
||||||
|
|
||||||
|
async def execute(stmt):
|
||||||
|
commit_out["sql"] = str(stmt)
|
||||||
|
result = MagicMock()
|
||||||
|
result.rowcount = 2
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def commit():
|
||||||
|
commit_out["commit"] = commit_out.get("commit", 0) + 1
|
||||||
|
|
||||||
|
session.execute = execute
|
||||||
|
session.commit = commit
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fail_stale_updates_pending_and_running_only():
|
||||||
|
"""P1-E: UPDATE 必须过滤 status IN ('pending','running'),SET status=failed。"""
|
||||||
|
out = {}
|
||||||
|
fake = _make_fake_session(out)
|
||||||
|
|
||||||
|
class FakeSessionLocal:
|
||||||
|
def __init__(self):
|
||||||
|
self._s = fake
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self._s
|
||||||
|
|
||||||
|
async def __aexit__(self, *a):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# patch src.db.base.AsyncSessionLocal(函数内 from-import 每次调用从此取)
|
||||||
|
import src.db.base as _base
|
||||||
|
|
||||||
|
with patch.object(_base, "AsyncSessionLocal", FakeSessionLocal):
|
||||||
|
await app_mod._fail_stale_ingest_jobs()
|
||||||
|
|
||||||
|
sql = out.get("sql", "").lower()
|
||||||
|
# WHERE 子句过滤 status IN (绑定参数,SQLAlchemy 用 __[postcompile_x] 占位)
|
||||||
|
assert "status in" in sql, f"SQL 缺少 status IN 过滤: {sql}"
|
||||||
|
# SET status=failed
|
||||||
|
assert "status=:status" in sql or "status=" in sql, f"SQL 缺少 status 更新: {sql}"
|
||||||
|
# 应清理成功(commit 被调用)
|
||||||
|
assert out.get("commit", 0) >= 1, f"应已提交,实际 commit 调用: {out}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fail_stale_does_not_raise_on_db_error():
|
||||||
|
"""P1-E: DB 异常不得阻断启动(仅记 warning)。"""
|
||||||
|
class BoomSessionLocal:
|
||||||
|
async def __aenter__(self):
|
||||||
|
raise RuntimeError("DB down")
|
||||||
|
|
||||||
|
async def __aexit__(self, *a):
|
||||||
|
return None
|
||||||
|
|
||||||
|
import src.db.base as _base
|
||||||
|
|
||||||
|
with patch.object(_base, "AsyncSessionLocal", BoomSessionLocal):
|
||||||
|
# 不应抛异常
|
||||||
|
await app_mod._fail_stale_ingest_jobs()
|
||||||
|
assert True
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""P1-F 回归测试: BIGINT 主键表 INSERT 不带 id,验证自动生成。
|
||||||
|
|
||||||
|
运行: pytest tests/test_p1_f_bigint_identity.py -v
|
||||||
|
(依赖真实 PG;无 PG 时跳过。禁止 SQLite 冒充。)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from src.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def _make_engine():
|
||||||
|
"""测试用独立 engine(避免全局 engine 的事件循环绑定问题)."""
|
||||||
|
url = settings.DATABASE_URL
|
||||||
|
return create_async_engine(url, pool_size=1, max_overflow=0, pool_pre_ping=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def _can_connect() -> bool:
|
||||||
|
try:
|
||||||
|
eng = _make_engine()
|
||||||
|
async with eng.begin() as conn:
|
||||||
|
pass
|
||||||
|
await eng.dispose()
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _skip_without_pg():
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
if not asyncio.run(_can_connect()):
|
||||||
|
pytest.skip("无真实 PG 可用,跳过 P1-F 测试(禁止 SQLite 冒充)")
|
||||||
|
|
||||||
|
|
||||||
|
class TestBigIntIdentity:
|
||||||
|
"""P1-F: BIGINT 主键表 INSERT 不带 id,DB 自动生成。"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def db(self):
|
||||||
|
eng = _make_engine()
|
||||||
|
SessionLocal = sessionmaker(eng, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
yield session
|
||||||
|
await eng.dispose()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_data_lineage_insert_without_id(self, db):
|
||||||
|
from src.db.models import DataLineage
|
||||||
|
|
||||||
|
row = DataLineage(
|
||||||
|
source_system="test", source_record_id="r1_p1f",
|
||||||
|
target_table="standings", target_id=None,
|
||||||
|
transform_name="t", transform_detail={},
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
await db.flush()
|
||||||
|
# 核心:未指定 id,DB 自动生成非 None
|
||||||
|
assert row.id is not None, "data_lineage.id 应自动生成"
|
||||||
|
assert isinstance(row.id, int)
|
||||||
|
assert row.id > 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_raw_event_insert_without_id(self, db):
|
||||||
|
from src.db.models import RawEvent
|
||||||
|
|
||||||
|
row = RawEvent(
|
||||||
|
source_system="test", source_record_id="r2_p1f",
|
||||||
|
raw_payload={"k": "v"},
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
await db.flush()
|
||||||
|
assert row.id is not None
|
||||||
|
assert row.id > 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_auto_increment_unique(self, db):
|
||||||
|
"""连续 INSERT 应产生递增唯一 id。"""
|
||||||
|
from src.db.models import DataLineage
|
||||||
|
|
||||||
|
r1 = DataLineage(
|
||||||
|
source_system="test", source_record_id="a_p1f",
|
||||||
|
target_table="t", transform_name="x",
|
||||||
|
)
|
||||||
|
r2 = DataLineage(
|
||||||
|
source_system="test", source_record_id="b_p1f",
|
||||||
|
target_table="t", transform_name="x",
|
||||||
|
)
|
||||||
|
db.add_all([r1, r2])
|
||||||
|
await db.flush()
|
||||||
|
assert r1.id is not None and r2.id is not None
|
||||||
|
assert r1.id != r2.id, "连续 INSERT id 应唯一"
|
||||||
|
assert r2.id > r1.id, "后插入的 id 应更大(递增)"
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""P1-G 回归测试: runtime_config DB 回落 env + 解密失败生产环境抛出。
|
||||||
|
|
||||||
|
运行: pytest tests/test_p1_g_runtime_config.py -v
|
||||||
|
(纯函数测试,mock session,无真实 PG 依赖。)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.core.config import settings
|
||||||
|
import src.core.runtime_config as rc
|
||||||
|
|
||||||
|
|
||||||
|
# ── 辅助:构造模拟 session ─────────────────────────────────────────
|
||||||
|
class _FakeRow:
|
||||||
|
def __init__(self, value): self.value = value
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeSession:
|
||||||
|
def __init__(self, row=None, raise_on_get=None):
|
||||||
|
self._row = row
|
||||||
|
self._raise = raise_on_get
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *a):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get(self, cls, key):
|
||||||
|
if self._raise:
|
||||||
|
raise self._raise
|
||||||
|
return self._row
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeSessionLocal:
|
||||||
|
def __init__(self, row=None, raise_on_get=None):
|
||||||
|
self._row = row
|
||||||
|
self._raise = raise_on_get
|
||||||
|
|
||||||
|
def __call__(self):
|
||||||
|
return _FakeSession(self._row, self._raise)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 测试 ──────────────────────────────────────────────────────────
|
||||||
|
class TestDBFallback:
|
||||||
|
"""P1-G:DB 故障时回落环境变量。"""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_db_error_falls_back_to_env(self):
|
||||||
|
"""DB 抛异常 → 回落 settings 同名属性。"""
|
||||||
|
# 模拟 DB 连接失败
|
||||||
|
fake = _FakeSessionLocal(raise_on_get=RuntimeError("DB down"))
|
||||||
|
with patch.object(rc, "AsyncSessionLocal", fake):
|
||||||
|
val = await rc.get_runtime_value("LLM_MODEL")
|
||||||
|
# 应回落 settings.LLM_MODEL(有默认值 gpt-4o)
|
||||||
|
assert val == settings.LLM_MODEL
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_db_returns_none_falls_back_to_env(self):
|
||||||
|
"""DB 行不存在 → 回落 env。"""
|
||||||
|
fake = _FakeSessionLocal(row=None)
|
||||||
|
with patch.object(rc, "AsyncSessionLocal", fake):
|
||||||
|
val = await rc.get_runtime_value("LLM_MODEL")
|
||||||
|
assert val == settings.LLM_MODEL
|
||||||
|
|
||||||
|
|
||||||
|
class TestDecryptFailure:
|
||||||
|
"""P1-G:解密失败在生产环境必须抛出,不得被 except Exception 吞掉。"""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_decrypt_failure_raises_in_production(self):
|
||||||
|
"""P1-G:production + 解密失败 → 必须 raise ValueError(不得被吞)。"""
|
||||||
|
from src.core import crypto
|
||||||
|
|
||||||
|
# 模拟 DB 返回一个加密值,但解密会失败
|
||||||
|
fake = _FakeSessionLocal(row=_FakeRow("enc:v1:corrupted_token"))
|
||||||
|
|
||||||
|
with patch.object(rc, "AsyncSessionLocal", fake), \
|
||||||
|
patch.object(crypto, "decrypt_value", side_effect=ValueError("解密失败")), \
|
||||||
|
patch.object(settings, "APP_ENV", "production"):
|
||||||
|
# P1-G:生产环境解密失败必须抛出,不得静默回落
|
||||||
|
with pytest.raises(ValueError, match="解密失败"):
|
||||||
|
await rc.get_setting_origin("LLM_API_KEY")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_decrypt_failure_falls_back_in_non_production(self):
|
||||||
|
"""非生产环境 + 解密失败 → 回落 env,不抛错。"""
|
||||||
|
from src.core import crypto
|
||||||
|
|
||||||
|
fake = _FakeSessionLocal(row=_FakeRow("enc:v1:corrupted_token"))
|
||||||
|
|
||||||
|
with patch.object(rc, "AsyncSessionLocal", fake), \
|
||||||
|
patch.object(crypto, "decrypt_value", side_effect=ValueError("解密失败")), \
|
||||||
|
patch.object(settings, "APP_ENV", "development"):
|
||||||
|
origin, val = await rc.get_setting_origin("LLM_API_KEY")
|
||||||
|
# 非生产 → 回落 env(origin=env 或 none)
|
||||||
|
assert origin in ("env", "none")
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""P1-K 回归测试: Team/League get_or_create 使用 PG UPSERT(ON CONFLICT DO NOTHING)。
|
||||||
|
|
||||||
|
运行: pytest tests/test_p1_k_upsert.py -v
|
||||||
|
(依赖真实 PG;无 PG 时跳过。)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from src.core.config import settings
|
||||||
|
from src.db.models import League, Team
|
||||||
|
from src.db.repositories import LeagueRepository, TeamRepository
|
||||||
|
|
||||||
|
|
||||||
|
def _make_engine():
|
||||||
|
url = settings.DATABASE_URL
|
||||||
|
return create_async_engine(url, pool_size=1, max_overflow=0, pool_pre_ping=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def _can_connect() -> bool:
|
||||||
|
try:
|
||||||
|
eng = _make_engine()
|
||||||
|
async with eng.begin() as conn:
|
||||||
|
pass
|
||||||
|
await eng.dispose()
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _skip_without_pg():
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
if not asyncio.run(_can_connect()):
|
||||||
|
pytest.skip("无真实 PG 可用,跳过 P1-K 测试")
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpsertBehavior:
|
||||||
|
@pytest.fixture
|
||||||
|
async def db(self):
|
||||||
|
eng = _make_engine()
|
||||||
|
SessionLocal = sessionmaker(eng, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
yield session
|
||||||
|
await eng.dispose()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_league_get_or_create_inserts_new(self, db):
|
||||||
|
repo = LeagueRepository(db)
|
||||||
|
league = await repo.get_or_create("TST", "Test League", "X")
|
||||||
|
assert league.id is not None
|
||||||
|
assert league.code == "TST"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_league_get_or_create_idempotent(self, db):
|
||||||
|
"""P1-K: 重复调用返回同一行,不创建重复。"""
|
||||||
|
repo = LeagueRepository(db)
|
||||||
|
a = await repo.get_or_create("IDP", "Idempotent", "X")
|
||||||
|
b = await repo.get_or_create("IDP", "Idempotent", "X")
|
||||||
|
assert a.id == b.id, "重复调用应返回同一行"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_team_get_or_create_inserts_new(self, db):
|
||||||
|
repo = TeamRepository(db)
|
||||||
|
team = await repo.get_or_create("Unique Team FC_k1")
|
||||||
|
assert team.id is not None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_team_get_or_create_idempotent(self, db):
|
||||||
|
"""P1-K: 重复调用返回同一行(UPSERT 防并发重复)。"""
|
||||||
|
repo = TeamRepository(db)
|
||||||
|
a = await repo.get_or_create("Same Team_k1")
|
||||||
|
b = await repo.get_or_create("Same Team_k1")
|
||||||
|
assert a.id == b.id, "重复调用应返回同一行"
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
验证:
|
验证:
|
||||||
1. 唯一约束包含 mode + run_type
|
1. 唯一约束包含 mode + run_type
|
||||||
2. 同一场比赛 live 与 backtest 预测可共存,互不覆盖
|
2. 同一场比赛 live 与 backtest 预测可共存,互不覆盖
|
||||||
3. _upsert_prediction 正确区分 run_type
|
3. _insert_or_find_by_fingerprint 正确区分 run_type
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
import pytest
|
import pytest
|
||||||
|
from sqlalchemy import Index
|
||||||
|
|
||||||
from src.db.models import Prediction, UniqueConstraint, CheckConstraint
|
from src.db.models import Prediction, UniqueConstraint, CheckConstraint
|
||||||
|
|
||||||
@@ -22,20 +23,32 @@ MIGRATION_PATH = REPO_ROOT / "alembic" / "versions" / "0013_predictions_unique_c
|
|||||||
|
|
||||||
|
|
||||||
class TestUniqueConstraint:
|
class TestUniqueConstraint:
|
||||||
"""验证唯一约束包含 mode + run_type。"""
|
"""P0-03: 验证幂等指纹唯一索引(替代旧 (match, provider, model, mode, run_type) 唯一约束)。"""
|
||||||
|
|
||||||
def test_constraint_columns(self):
|
def test_input_hash_partial_unique_index(self):
|
||||||
"""唯一约束应包含 match_id, provider, model, mode, run_type。"""
|
"""P0-03: input_hash 非空时必须唯一(同指纹 → 返回已有行,不 UPDATE/INSERT)。"""
|
||||||
uc = [
|
idx = [
|
||||||
c for c in Prediction.__table__.constraints
|
i for i in Prediction.__table__.indexes
|
||||||
if isinstance(c, UniqueConstraint) and "match" in c.name
|
if i.unique and "input_hash" in i.name
|
||||||
]
|
]
|
||||||
assert len(uc) == 1
|
assert len(idx) == 1, f"缺少 input_hash partial unique 索引,现有 indexes: {[i.name for i in Prediction.__table__.indexes]}"
|
||||||
cols = [c.name for c in uc[0].columns]
|
# partial unique: postgresql_where 必须限制 input_hash IS NOT NULL
|
||||||
assert cols == ["match_id", "provider", "model", "mode", "run_type"]
|
assert idx[0].dialect_kwargs.get("postgresql_where") is not None
|
||||||
|
|
||||||
|
def test_old_unique_constraint_removed(self):
|
||||||
|
"""P0-03: 旧 (match, provider, model, mode, run_type) 唯一约束必须已移除。"""
|
||||||
|
from sqlalchemy import UniqueConstraint
|
||||||
|
|
||||||
|
old = [
|
||||||
|
c for c in Prediction.__table__.constraints
|
||||||
|
if isinstance(c, UniqueConstraint) and c.name == "uq_predictions_match_provider_model_mode_run_type"
|
||||||
|
]
|
||||||
|
assert len(old) == 0, f"旧约束必须已移除,但仍存在: {[c.name for c in old]}"
|
||||||
|
|
||||||
def test_run_type_check_constraint(self):
|
def test_run_type_check_constraint(self):
|
||||||
"""应有 run_type 的 check constraint。"""
|
"""应有 run_type 的 check constraint。"""
|
||||||
|
from sqlalchemy import CheckConstraint
|
||||||
|
|
||||||
cc = [
|
cc = [
|
||||||
c for c in Prediction.__table__.constraints
|
c for c in Prediction.__table__.constraints
|
||||||
if isinstance(c, CheckConstraint) and "run_type" in c.name
|
if isinstance(c, CheckConstraint) and "run_type" in c.name
|
||||||
@@ -52,13 +65,16 @@ class TestUniqueConstraint:
|
|||||||
|
|
||||||
|
|
||||||
class TestUpsertPredictionSignature:
|
class TestUpsertPredictionSignature:
|
||||||
"""验证 _upsert_prediction 函数签名包含 run_type。"""
|
"""验证 _insert_or_find_by_fingerprint 签名(P0-03 指纹模式)。"""
|
||||||
|
|
||||||
def test_signature_has_run_type(self):
|
def test_signature_uses_values_dict(self):
|
||||||
from src.llm.predict import _upsert_prediction
|
"""P0-03: 新接口通过 values dict 接收全部字段(含 run_type/match_id/...)。"""
|
||||||
|
from src.llm.predict import _insert_or_find_by_fingerprint
|
||||||
|
|
||||||
sig = inspect.signature(_upsert_prediction)
|
sig = inspect.signature(_insert_or_find_by_fingerprint)
|
||||||
assert "run_type" in sig.parameters
|
params = sig.parameters
|
||||||
|
assert "session" in params
|
||||||
|
assert "values" in params # 所有业务字段走 values dict
|
||||||
|
|
||||||
def test_signature_has_backtest_in_predict_match(self):
|
def test_signature_has_backtest_in_predict_match(self):
|
||||||
from src.llm.predict import predict_match
|
from src.llm.predict import predict_match
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import re
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.db.models import Team, TeamAlias
|
from src.db.models import League, Team, TeamAlias
|
||||||
from src.data.key_ring import _mask
|
from src.data.key_ring import _mask
|
||||||
from src.llm import backtest as bt_mod
|
from src.llm import backtest as bt_mod
|
||||||
from src.llm.agents import orchestrator as orch_mod
|
from src.llm.agents import orchestrator as orch_mod
|
||||||
@@ -107,6 +107,7 @@ class _FakeDb:
|
|||||||
self.flush_count = 0
|
self.flush_count = 0
|
||||||
self._next_id = 1000
|
self._next_id = 1000
|
||||||
self._teams_by_id: dict[int, Team] = {}
|
self._teams_by_id: dict[int, Team] = {}
|
||||||
|
self._teams_by_name: dict[str, Team] = {}
|
||||||
self._aliases: dict[str, TeamAlias] = {}
|
self._aliases: dict[str, TeamAlias] = {}
|
||||||
|
|
||||||
async def get(self, cls, key):
|
async def get(self, cls, key):
|
||||||
@@ -117,6 +118,38 @@ class _FakeDb:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
async def execute(self, _stmt):
|
async def execute(self, _stmt):
|
||||||
|
# P1-K:get_or_create 使用 pg_insert(Insert 语句)→ 解析值并注册实体
|
||||||
|
if not hasattr(_stmt, "column_descriptions"):
|
||||||
|
try:
|
||||||
|
values = _stmt.compile().params
|
||||||
|
except Exception:
|
||||||
|
values = {}
|
||||||
|
table = getattr(_stmt, "table", None)
|
||||||
|
table_name = getattr(table, "name", None) if table is not None else None
|
||||||
|
entity_map = {"leagues": League, "teams": Team}
|
||||||
|
entity = entity_map.get(table_name)
|
||||||
|
if entity is not None:
|
||||||
|
obj = entity()
|
||||||
|
for k, v in values.items():
|
||||||
|
if k in ("code", "name", "country", "name_zh"):
|
||||||
|
setattr(obj, k, v)
|
||||||
|
if getattr(obj, "id", None) is None:
|
||||||
|
self._next_id += 1
|
||||||
|
obj.id = self._next_id
|
||||||
|
if entity is Team:
|
||||||
|
self._teams_by_id[obj.id] = obj
|
||||||
|
self._teams_by_name[obj.name] = obj
|
||||||
|
return _FakeResult([])
|
||||||
|
# P1-K:Team 查询回退到内存映射(避免队列耗尽)
|
||||||
|
try:
|
||||||
|
params = _stmt.compile().params
|
||||||
|
except Exception:
|
||||||
|
params = {}
|
||||||
|
name_val = next((v for k, v in params.items() if "name" in k), None)
|
||||||
|
if isinstance(name_val, str) and name_val:
|
||||||
|
team = self._teams_by_name.get(name_val)
|
||||||
|
if team:
|
||||||
|
return _FakeResult([team])
|
||||||
if self._results:
|
if self._results:
|
||||||
return self._results.pop(0)
|
return self._results.pop(0)
|
||||||
return _FakeResult([])
|
return _FakeResult([])
|
||||||
@@ -194,8 +227,8 @@ async def test_r2_standings_actually_upserts(monkeypatch):
|
|||||||
assert first.zone == "Champions League" # 优先取 label
|
assert first.zone == "Champions League" # 优先取 label
|
||||||
|
|
||||||
|
|
||||||
async def test_r2_standings_upsert_updates_existing(monkeypatch):
|
async def test_r2_standings_append_new_row(monkeypatch):
|
||||||
"""行为测试: 已存在同 (league, season, team) 时应就地更新而非新增。"""
|
"""P0-02 行为测试: 每次采集 INSERT 新行(带 available_at),不更新旧行。"""
|
||||||
import src.data.bzzoiro as bz
|
import src.data.bzzoiro as bz
|
||||||
from src.db.models import League, Standing
|
from src.db.models import League, Standing
|
||||||
|
|
||||||
@@ -217,16 +250,20 @@ async def test_r2_standings_upsert_updates_existing(monkeypatch):
|
|||||||
existing = Standing(league_id=42, season="2025-2026", team_id=7, position=9)
|
existing = Standing(league_id=42, season="2025-2026", team_id=7, position=9)
|
||||||
existing.points = 1
|
existing.points = 1
|
||||||
|
|
||||||
# 查询顺序: League → Team 预载(命中) → Standing 查询(命中已有行)
|
# 查询顺序: League(命中) → Team 预载(命中) → (P0-02 不再查询 Standing)
|
||||||
db = _FakeDb(results=[_FakeResult([league]), _FakeResult([team]), _FakeResult([existing])])
|
db = _FakeDb(results=[_FakeResult([league]), _FakeResult([team])])
|
||||||
|
|
||||||
result = await bz.ingest_bzzoiro_standings(db, leagues=["EPL"])
|
result = await bz.ingest_bzzoiro_standings(db, leagues=["EPL"])
|
||||||
|
|
||||||
assert result["total_upserted"] == 1
|
assert result["total_upserted"] == 1
|
||||||
assert existing.points == 30, "已有行应被就地更新"
|
# P0-02: 追加快照——新增 Standing 行,旧行不被修改
|
||||||
|
new_rows = [o for o in db.added if isinstance(o, Standing)]
|
||||||
|
assert len(new_rows) == 1, "P0-02 应新增一条 Standing 行"
|
||||||
|
assert new_rows[0].points == 30, "新行应承载新采集数据"
|
||||||
|
assert new_rows[0].available_at is not None, "新行必须含 available_at"
|
||||||
|
# 旧行未被修改(仍保持原值)
|
||||||
|
assert existing.points == 1, "P0-02 旧行不应被覆盖"
|
||||||
assert result["leagues"]["EPL"]["teams_created"] == 0
|
assert result["leagues"]["EPL"]["teams_created"] == 0
|
||||||
# 不应新增 Standing(只有 league/team 层面的 add)
|
|
||||||
assert not [o for o in db.added if isinstance(o, Standing)]
|
|
||||||
|
|
||||||
|
|
||||||
def test_r2_source_contains_real_upsert_loop():
|
def test_r2_source_contains_real_upsert_loop():
|
||||||
@@ -237,7 +274,9 @@ def test_r2_source_contains_real_upsert_loop():
|
|||||||
assert "total_upserted" in src
|
assert "total_upserted" in src
|
||||||
assert 'result["total_upserted"] +=' in src, "total_upserted 必须真的被累加"
|
assert 'result["total_upserted"] +=' in src, "total_upserted 必须真的被累加"
|
||||||
assert "Standing(" in src, "必须真的构造 Standing"
|
assert "Standing(" in src, "必须真的构造 Standing"
|
||||||
assert "select(Standing)" in src, "必须查询已有快照以决定 insert/update"
|
# P0-02: 追加快照——每次 INSERT 新行(带 available_at),不查询旧行做 upsert
|
||||||
|
assert "available_at" in src, "P0-02 采集必须设置 available_at"
|
||||||
|
assert "scalar_one_or_none" not in src, "P0-02 不应再按 (league, season, team) 做 upsert 查询"
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -423,7 +462,7 @@ async def test_r4_dispatch_passes_override_to_specialists(monkeypatch):
|
|||||||
|
|
||||||
monkeypatch.setattr(orch_mod, "load_match_header", _fake_header, raising=True)
|
monkeypatch.setattr(orch_mod, "load_match_header", _fake_header, raising=True)
|
||||||
monkeypatch.setattr(orch_mod, "run_specialists", _fake_specialists, raising=True)
|
monkeypatch.setattr(orch_mod, "run_specialists", _fake_specialists, raising=True)
|
||||||
monkeypatch.setattr(orch_mod, "_upsert_prediction", _fake_upsert, raising=True)
|
monkeypatch.setattr(orch_mod, "_insert_or_find_by_fingerprint", _fake_upsert, raising=True)
|
||||||
monkeypatch.setattr(orch_mod, "get_uow", _FakeUow, raising=True)
|
monkeypatch.setattr(orch_mod, "get_uow", _FakeUow, raising=True)
|
||||||
|
|
||||||
assert get_uow is not None # 确保 import 生效,session 未被真实打开
|
assert get_uow is not None # 确保 import 生效,session 未被真实打开
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ class _FakeDB:
|
|||||||
}
|
}
|
||||||
# session.get 查找表(Team/TeamAlias)
|
# session.get 查找表(Team/TeamAlias)
|
||||||
self._teams_by_id: dict[int, Team] = {t.id: t for t in teams if getattr(t, "id", None)}
|
self._teams_by_id: dict[int, Team] = {t.id: t for t in teams if getattr(t, "id", None)}
|
||||||
|
self._teams_by_name: dict[str, Team] = {t.name: t for t in teams if getattr(t, "id", None)}
|
||||||
self._aliases: dict[str, TeamAlias] = {}
|
self._aliases: dict[str, TeamAlias] = {}
|
||||||
self._next_id = max((t.id for t in teams if getattr(t, "id", None)), default=0)
|
self._next_id = max((t.id for t in teams if getattr(t, "id", None)), default=0)
|
||||||
|
|
||||||
@@ -94,6 +95,51 @@ class _FakeDB:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
async def execute(self, stmt):
|
async def execute(self, stmt):
|
||||||
|
# P1-K:LeagueRepository.get_or_create 使用 pg_insert(Insert 语句,无 column_descriptions)
|
||||||
|
if not hasattr(stmt, "column_descriptions"):
|
||||||
|
# 解析 INSERT 值并注册到 _by_entity,使二次查询可命中
|
||||||
|
try:
|
||||||
|
values = stmt.compile().params
|
||||||
|
except Exception:
|
||||||
|
values = {}
|
||||||
|
# 推断实体类型(League/Team)从 inserted 的表名
|
||||||
|
table = getattr(stmt, "table", None)
|
||||||
|
table_name = getattr(table, "name", None) if table is not None else None
|
||||||
|
entity_map = {"leagues": League, "teams": Team}
|
||||||
|
entity = entity_map.get(table_name)
|
||||||
|
if entity is not None:
|
||||||
|
obj = entity()
|
||||||
|
for k, v in values.items():
|
||||||
|
if k in ("code", "name", "country", "name_zh"):
|
||||||
|
setattr(obj, k, v)
|
||||||
|
# 分配 id 并注册
|
||||||
|
if getattr(obj, "id", None) is None:
|
||||||
|
self._next_id += 1
|
||||||
|
obj.id = self._next_id
|
||||||
|
if entity not in self._by_entity:
|
||||||
|
self._by_entity[entity] = []
|
||||||
|
self._by_entity[entity].append(obj)
|
||||||
|
if entity is Team:
|
||||||
|
self._teams_by_id[obj.id] = obj
|
||||||
|
self._teams_by_name[obj.name] = obj
|
||||||
|
class _Empty:
|
||||||
|
def scalar_one_or_none(self_inner):
|
||||||
|
return None
|
||||||
|
def scalars(self_inner):
|
||||||
|
return self_inner
|
||||||
|
def all(self_inner):
|
||||||
|
return []
|
||||||
|
return _Empty()
|
||||||
|
# P1-K:Team 查询回退到内存映射 name
|
||||||
|
try:
|
||||||
|
params = stmt.compile().params
|
||||||
|
except Exception:
|
||||||
|
params = {}
|
||||||
|
name_val = next((v for k, v in params.items() if k.startswith("name")), None)
|
||||||
|
if isinstance(name_val, str) and name_val:
|
||||||
|
team = self._teams_by_name.get(name_val)
|
||||||
|
if team:
|
||||||
|
return _FakeResult([team])
|
||||||
entities = set()
|
entities = set()
|
||||||
for d in (stmt.column_descriptions or []):
|
for d in (stmt.column_descriptions or []):
|
||||||
entities.add(d.get("entity") or d.get("type"))
|
entities.add(d.get("entity") or d.get("type"))
|
||||||
@@ -267,8 +313,8 @@ class TestStandingsBronzeIsBestEffort:
|
|||||||
raise RuntimeError("infra down")
|
raise RuntimeError("infra down")
|
||||||
|
|
||||||
# Bronze 写入助手直接 import 到 bzzoiro_standings 命名空间,需 patch 该处
|
# Bronze 写入助手直接 import 到 bzzoiro_standings 命名空间,需 patch 该处
|
||||||
monkeypatch.setattr(bz_standings, "_write_raw_event", _boom)
|
monkeypatch.setattr("src.data.pipeline_write._write_raw_event", _boom)
|
||||||
monkeypatch.setattr(bz_standings, "_write_lineage", _boom)
|
monkeypatch.setattr("src.data.pipeline_write._write_lineage", _boom)
|
||||||
_patch_fetch(monkeypatch, _payload())
|
_patch_fetch(monkeypatch, _payload())
|
||||||
db = _FakeDB(leagues=[_preset_league()])
|
db = _FakeDB(leagues=[_preset_league()])
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user