81 Commits
Author SHA1 Message Date
shangfangjian 64ae8e663a fix(P0-03): Prediction 幂等指纹——只追加,不覆盖
_upsert_prediction 改为 _insert_or_find_by_fingerprint:
- 同 input_hash → 返回已有行(绝不 UPDATE pred_/reasoning/agent_outputs)
- 不同 input_hash → INSERT 新行

input_hash 升级为规范 JSON SHA-256,捕获:match_id, cutoff, prompt_version,
prompt_hash, system_prompt_hash, provider, model, mode, run_type, temperature,
context_hash, agent_ids。移除旧 (match, provider, model, mode, run_type) 唯一约束,
改为 partial unique index(WHERE input_hash IS NOT NULL,兼容旧 NULL 数据)。

三条路径(single/multi/baseline)统一传足指纹字段。
迁移 0024 + 测试 test_p0_prediction_fingerprint(10/10);全量 295 通过。
2026-09-22 03:13:32 +08:00
shangfangjian 49d78136a1 fix(P0-02): 积分榜改为追加快照(append-only) + available_at cutoff
去掉 uq_standings_league_season_team,改为 (league, season, team, available_at) 唯一;
每次采集 INSERT 新行(available_at=now),ON CONFLICT DO NOTHING,不覆盖旧行。

standings_slice(before):DISTINCT ON (team_id) WHERE available_at<=cutoff
ORDER available_at DESC;before=None → cutoff=now()。
公开 list_standings 取每队最新可用快照(子查询 max available_at)。

迁移 0023 + 切片/路由/docs 同步;测试 test_p0_standings_cutoff(5/5)。
284 测试全绿。
2026-09-22 02:45:50 +08:00
shangfangjian 63caa6736c fix(P0-01): missing score 不得变 0:0 — score_status + CHECK 约束
新增 matches.score_status(known/missing/unknown):
- 替换 ck_matches_finished_has_score 为 ck_matches_score_integrity:
  known → 必须有分; missing/unknown → goals 必须 NULL(不伪造 0:0)
- normalize: 完赛缺分不再静默降级为 scheduled,改设 score_status=missing
- events ingest: 创建/更新 Match 同步 score_status(比分由缺变 known / 确认缺分 missing)
- slices(form/h2h/home_away)/backtest: 显式加 score_status='known' 过滤完赛样本
- 迁移 0022 回填现有数据(绝不 UPDATE goals=0)

测试 tests/test_p0_score_status.py(9/9):约束存在性/Match 构造/normalize 行为。
54 相关测试全绿。
2026-09-22 02:32:53 +08:00
shangfangjian f563d5cc99 fix(P0-00): HTTP client 必须传递 method/body,仅 body 存在时设 Content-Type
request() 此前忽略 options.method(默认 GET)与 options.body,导致 POST/PUT/DELETE
全部以 GET 空 body 发出;且无条件设置 Content-Type 误污染无 body 请求。

修复: 显式传递 method(默认 GET) 与 body; 仅当 body 非空时加 Content-Type。

测试 frontend/src/lib/http.test.ts(5/5):GET/POST/PUT/DELETE 的 method/body/headers。
运行: node --experimental-transform-types frontend/src/lib/http.test.ts
2026-09-22 02:24:50 +08:00
shangfangjian 7d2eabf750 chore: 死代码与重复逻辑清理
删除未引用/未调用符号:
- PredictionRepository(无引用)
- is_correct_1x2(无调用者)
- LeagueOut(路由用 list[dict])
- IngestResponse(IngestBzzoiroResponse 已替代)
- SecurityCheckError(从未 raise,assert_security_on_startup 用 sys.exit)
- short_write(仅自引用,全仓库无外部调用)
- fetchIngestJobs(列表函数无页面使用,单数 fetchIngestJob 仍保留)
- clear_prompt_cache(无入口)

去重:
- eval._actual_1x2 改为委托 utils.actual_1x2(单一权威源)

全量测试 270 通过,业务行为不变。
2026-09-22 01:55:52 +08:00
shangfangjian a00364d4a7 perf+unify: matches 列表查询优化 + Standings 统一数据源
matches 列表:selectinload 加 load_only 限定列(League.code/Team.name,name_zh/
MatchStats.home_xg,away_xg),补 stats 加载消除 N+1(m.stats 此前懒加载)。
详情接口保持完整 options;游标分页/响应字段/空值语义不变。

Standings 改用 useLeagues() 统一数据源(API 优先,失败回退本地常量),
LEAGUES 常量扩展 CL/EL;无数据联赛显示虚线 tab + 空态(非隐藏)。
2026-09-22 01:35:06 +08:00
shangfangjian 92f50fa5d4 feat: 采集任务状态跟踪 ingest_jobs
新增 ingest_jobs 表(UUID/task/params/status/result/error/时间戳),
POST /ingest/bzzoiro 启动前插入 job(pending)→ 后台 running → success/failed,
响应新增 job_id(兼容原 message)。

Admin GET /admin/ingest/jobs/{id} 与 /admin/ingest/jobs?limit= 只读查询;
Collection 页提交后轮询 job 至终态,展示真实 result/error 汇总。
迁移 0019_ingest_jobs + 分批 get_uow/BzzoiroSource/IngestFailure 不变。

全量测试 270 通过。
2026-09-22 01:34:55 +08:00
shangfangjian 44816794d3 refactor: context_builder 按 slice 拆到 src/llm/slices/ 包
单文件拆分(仅搬迁无逻辑修改):
- common.py    共享类型/头信息/_outcome/_is_stats_available
- form.py      form_slice + _get_form
- h2h.py       h2h_slice + _get_h2h
- stats.py     stats_slice(复用 form._get_form)
- home_away.py home_away_slice + _get_home_away
- standings.py standings_slice
- aggregate.py build_context

context_builder.py 改为纯 re-export 门面,公开签名不变。
同步修复测试 patch 目标(p0_home_away/h2h_perspective/multi_agent_cutoff)
与 regressions 源码断言(读 slices/*.py)。

全量测试 270 通过。
2026-09-22 01:33:14 +08:00
shangfangjian f1016b610a docs: predict.py docstring 补落库层级表格
明确 single/multi/baseline 均在服务层(_upsert_prediction)落库,
路由层永不写入 predictions,仅做 result → PredictOut 映射。
2026-09-22 01:19:19 +08:00
shangfangjian 03727bda00 refactor: 管线写入依赖解耦——pipeline_write 直接 import
bzzoiro_events / bzzoiro_standings / bzzoiro_stats 直接 import
src.data.pipeline_write(_write_raw_event/_write_lineage/_safe_write_ingest_failure),
删除 bzzoiro.py 门面中的 pipeline_write 转发胶水。

保留 fetch_*/_fetch_json_async/REQUEST_INTERVAL 经 bz. 门面调用(测试 monkeypatch 入口);
测试 best-effort 改为 patch 管线模块自身命名空间(from-import 绑定语义)。
函数语义与「失败不拖垮主流程」不变;source_record_id/transform_name 约定不变。

全量测试 270 通过。
2026-09-22 01:15:45 +08:00
shangfangjian 3a56ee17e1 feat: 预测缓存可选 Redis 后端(PREDICT_CACHE_URL)
PREDICT_CACHE_URL 为空=进程内 LRU+TTL dict(默认,行为不变);
填 redis:// 启用 Redis,失败自动降级内存并 warning,不中断预测。

- _CacheBackend 接口 + _MemoryCache/_RedisCache 两个后端
- 同键格式(predict:{match}:{provider}:{model}:{version}:{tpl_hash[:12}])
- 同 TTL(300s);Redis 用 pickle 序列化
- redis 包未安装/连接失败 → 降级内存;不强制依赖 redis 启动
- clear_prompt_cache 同步清空内存预测缓存

全量测试 270 通过;缓存后端单测 4/4。
2026-09-22 00:53:27 +08:00
shangfangjian 7593b99e39 docs+feat: matches 唯一键语义明确化 + source_event_id partial unique
docs/05-data.md:业务唯一=同联赛同主客同自然天;
source_event_id 用于统计回填与血缘,新增部分唯一索引说明与 upsert 查找顺序。

新增 ix_matches_source_event_id_unique(WHERE IS NOT NULL),
兼容存量空值历史行;MatchRepository.find_by_source_event_id;
events upsert 优先按 event_id 定位,回退自然键。
迁移 0021 + 回归测试修复(find_by_source_event_id 方法调用误判)。
2026-09-22 00:41:54 +08:00
shangfangjian e15b554ba3 feat: 球队实体一致性 — 归一化咽喉 + team_aliases 别名机制
events/standings 创建 Team 前均经 team_names.normalize(已有,确认),
TeamRepository.get_or_create 收敛为归一化唯一咽喉 + info 日志。

新增 team_aliases 表(NFKD 归一别名 → teams.id FK CASCADE),
定位三步链:normalize(name) → teams.name → team_aliases → insert。
不自动合并历史重复队;提供 POST /api/v1/admin/teams/aliases 显式添加。

迁移 0020_team_aliases + Admin 别名管理端点(admin_teams.py)。
全量测试 270 通过。
2026-09-22 00:27:53 +08:00
shangfangjian 4b0d6ee58a chore(P3): baseline 落库下沉 + MatchPredictPanel 拆分 + 多 worker/CSRF 文档
P3-2 baseline 落库从路由下沉到服务层(predict_baseline 内直接落库),
删除路由层 _persist_baseline,三种模式统一 result.prediction_id,对外 JSON 不变。

P3-1 MatchPredictPanel.PredictionPanel 拆为 OutcomePanel/AgentsPanel/ReasoningPanel
三个子组件,本文件保留 PredictModal/PredictProgress/Spinner,对外导出路径不变。

P3-3 docs 加 ⚠️ 多 worker 陷阱红字 + STRICT_SINGLE_WORKER 环境变量(启动期强制拒绝多 worker)。
P3-4 docs 新增「同站部署 vs 跨站 CSRF」节。
2026-09-21 23:29:09 +08:00
shangfangjian 45497d2112 refactor: admin_settings.py 按职责拆分为 4 个模块
681 行单文件拆为(保留 admin_settings 为 include_router 聚合入口):
- admin_datasources  数据源列表/连通性测试/KeyRing/ingest status (5 路由)
- admin_config       settings CRUD + 运行日志 (4 路由)
- admin_llm          LLM agents/models/ping (3 路由)
- admin_quality      stats/data-completeness/data-quality (4 路由)

所有路由仍挂 /api/v1/admin 且带 dependencies=[Depends(require_admin)]。
app.py 注册方式不变(仍 import admin_settings.router)。
2026-09-21 23:28:58 +08:00
shangfangjian 66f0844798 feat: standings Bronze 血缘补齐 + 队名归一 MVP
standings 成功 upsert 后写入 RawEvent(source_record_id=standings:{league}:{season})
+ DataLineage(target_table=standings),与 events/stats 管线对称。

队名归一化收敛到 TeamRepository.get_or_create 唯一咽喉点,
创建新 Team 时 info 日志打出原始名与归一后名;
Admin 新增 GET /api/v1/admin/team-name-duplicates 只读接口,
启发式列出近似重名候选(大小写变体/子串/前缀碰撞),不做自动合并。
2026-09-21 23:28:46 +08:00
shangfangjian 317a5e338a refactor: bzzoiro.py 按管线拆分为 5 个模块
单文件 852 行按职责拆分,保持 BzzoiroSource 与 get_source("bzzoiro") 行为不变:
- bzzoiro_common  HTTP 抓取(多 key 轮换) + 字段转换原语
- bzzoiro_events   fetch_bzzoiro_events + BzzoiroSource.ingest + Bronze 补写
- bzzoiro_standings  standings 管线
- bzzoiro_stats     stats 回填
- pipeline_write    RawEvent/IngestFailure/DataLineage 写入助手

子模块运行期经聚合门面 src.data.bzzoiro 解析可替换协作者,
单文件时代的 bz.* monkeypatch 语义完全保留。
路由 import 已指向新模块(ingest.py / schedules.py)。
2026-09-21 23:27:48 +08:00
shangfangjian 7a5c695b89 Merge pull request '公开只读 API+ 全套文档对齐(P1-1)' (#11) from docs-public-api-fixes into main
Reviewed-on: #11
2026-09-21 21:38:58 +08:00
WorkBuddy 6bdb1f8ae6 docs(deploy): 补充生产上线检查清单(README + docs/06)
- docs/06 新增「生产上线检查清单」10 项:APP_ENV / SECRET_KEY 强随机 /
  管理鉴权二选一 / 数据库强密码 / HTTPS+Cookie Secure /
  TRUST_PROXY_HEADERS+X-Forwarded-For / Nginx limit_req 限流前置
  (应用内限流与 KeyRing 单进程限制) / uvicorn 单 worker /
  /health 与 /health/ready / alembic 迁移已内置
- README 在部署说明后加同名精简清单,链接到 docs/06 详情
- 全部条目均对齐真实实现(security_check 弱值黑名单、auth.py
  secure=production、deps.py TRUST_PROXY 解析、app.py 双健康检查、
  compose/Dockerfile 内置迁移);纯文档,未改业务逻辑
2026-09-21 21:02:16 +08:00
WorkBuddy 80616cf459 docs: 全套文档与代码对齐(P1-1,数据源收敛 bzzoiro 后的存量漂移清理)
- 数据源:全部文档统一为 bzzoiro 唯一来源,删除 understat/api-football
  作为现行数据源的表述(仅保留历史注记);.env.example/docs/06 移除
  失效的 API_FOOTBALL_KEY
- 多 Agent:injuries 阵容完整性 → standings 联赛排名
  (README/01/04 的专家表、数据流图、no_data 示例、agent_weights、
  prompt 清单全部对齐 form/stats/home_away/standings/h2h)
- 数据库:6 张表 → 12 张表,补 standings/app_settings/schedules 与
  4 张治理表说明(标注 raw_events/data_quality_checks/data_lineage
  为预留未启用,ingest_failures 已启用),predictions 补 agent_weights 列
- API 文档(03):新增鉴权模型三档表;context/standings/leagues 公开端点
  补全;predictions/eval/backtest 标注需管理员;ingest/bzzoiro 补
  task/limit/season 参数契约,删除 understat/injuries 端点小节
- 目录树(README/01/07):与真实 src/、frontend/src 结构一致
  (core 7 文件、data 7 文件、移除不存在的 retry.py)
- 采集命令(02/05):understat 回填 → task=stats/standings;
  补管理员凭据提示;docs/08 文首标注历史/过时
2026-09-21 20:55:48 +08:00
WorkBuddy a24017eb69 feat(api): 比赛上下文与联赛列表改公开只读(P1-2/P1-3)
- GET /matches/{id}/context 移除 require_admin:公开站详情页「近况/交锋」
  数据来源,匿名 401 会导致前端静默空态;响应结构不变,不触发 LLM
- GET /leagues 改公开只读:公开站联赛筛选动态加载;仅返回
  id/code/name/country 四个展示字段,不含配置或密钥信息
- 前端新增 useLeagues hook:优先请求 /api/v1/leagues,失败/空回退
  本地五大联赛常量(已知代码保留中文标签,新联赛按 API 名称追加)
- 新增 tests/test_public_readonly_api.py(6 项):匿名 200/404、响应形状、
  路由依赖声明检查(matches 路由无 require_admin)+ 判别力守卫
  (ingest 路由必须检出 require_admin,防检测器恒真)
2026-09-21 20:55:26 +08:00
shangfangjian 3056a95ef4 Merge pull request '工程债清理(Bronze 血缘/预测类型统一/Matches 拆分/Repository 治理/导航单源等)'
Reviewed-on: #10
2026-09-21 19:57:48 +08:00
WorkBuddy d9aeff2114 debt(D7): 限流/KeyRing 单进程限制说明 + 生产启动一次性告警
- app.py lifespan:APP_ENV=production 时 logger.warning 提醒
  进程内 rate-limit/KeyRing 仅单进程有效(不 sys.exit、不引入 Redis)
- key_ring.py docstring 补充多 worker 不共享的后果说明
- deps.py 注释已具备(单进程有效,生产前置 Nginx),无需改动
2026-09-21 19:51:41 +08:00
WorkBuddy 17e99a4f9a debt(D5): agent_outputs 类型标注对齐真实 JSON 形状(list[dict]|dict|None)
- Mapped[list[dict] | dict | None]:multi 模式存专家报告列表,
  历史数据/兼容路径可能存 dict;加注释说明形状来源
- 仅类型标注修正,列类型(JSONB)与数据、迁移均不变
2026-09-21 19:51:38 +08:00
WorkBuddy 4514ef4e92 debt(D4): bzzoiro Team/League/Match 查找经 Repository 层,纠正空壳表述
- repositories.py 新增 MatchRepository.find_by_league_and_date_range /
  find_finished_with_stats(含 stats 预加载),TeamRepository.get_or_create
  支持 name_zh
- bzzoiro events/standings/stats 三条管线的联赛、球队、比赛查找与批量预载
  全部改走 Repository;事务提交仍由调用方 UnitOfWork 控制
- Standing/RawEvent/Lineage 管线内私有读写保留在本模块(不强行 Repository 化)
- 模块 docstring 纠正为准确表述,移除「已全面 Repository 化」的误导性声明
2026-09-21 19:48:56 +08:00
WorkBuddy 18c89111d8 debt(D6): Admin 导航单源 —— NAV_ITEMS 派生侧栏/命令面板/面包屑
- 新增 admin/nav.ts: 唯一配置源 NAV_ITEMS(to/label/group/icon/hideFromSidebar)
- COMMAND_PALETTE_PAGES / ROUTE_LABELS / NAV_SECTIONS 全部由其派生
- 删除 AdminLayout 中三份平行清单(已出现漂移: data-pipeline 不在侧栏、
  「系统」vs「系统设置」命名不一致、monitoring/eval 顺序两处相反)
- 已知微差: 命令面板「评估与监控」组内顺序统一为侧栏口径(eval 在前)
- 行为不变: 路由/标签/分组标题均保持现状;data-pipeline 保持仅面板可达
2026-09-21 19:34:31 +08:00
WorkBuddy 52d67863d6 debt(D3): 拆分 Matches.tsx(~1400 行)为 hooks + 组件 + 共享层
- pages/matches/types.ts: Match/Prediction/AgentReport + 常量(LEAGUES/STATUS_META/AGENT_LABELS 等)
- pages/matches/ui.tsx: Spinner/SkeletonRows/Switch/日期分组工具(Switch 自组件体内提升到模块级)
- pages/matches/hooks/useMatchesList.ts: 列表筛选/游标分页/进行中比赛,竞态防护语义不变
- pages/matches/hooks/useMatchPredict.ts: 预测状态机(连点/竞态/中止/超时)+ 可读错误文案
- pages/matches/components/MatchPredictPanel.tsx: 预测弹窗全套(过程可视化/结果/专家意见)
- pages/matches/components/MatchDetailSection.tsx: MatchRow(原内联行 JSX 抽出)+ 详情面板
- Matches.tsx 组装页 279 行(< 300);error state 共享行为与拆分前一致
- 验证: tsc + vite build 通过,渲染逻辑原样搬迁
2026-09-21 19:31:42 +08:00
WorkBuddy 5c7fdce0a3 debt(D2): 统一预测结果类型为 PredictResult,路由去 dict 分支
- PredictResult 扩展可选字段 mode/agent_outputs/agent_weights/prompt_tokens/completion_tokens
- MultiPredictResult 变为 PredictResult 别名(保留 R4 守卫标记与 re-export)
- baseline 改返回 PredictResult(修复 backtest 对 baseline AttributeError 的潜伏 bug)
- 预测路由单一属性映射,删除全部 isinstance(result, dict) 分支
- _persist_baseline 属性化,baseline upsert 语义不变(prompt_version/token/latency 同前)
- TDD: 5 新测试 + test_baseline.py 属性化;全量 257 passed
2026-09-21 19:23:50 +08:00
WorkBuddy 3a9f3f5a0e debt(D1): events 成功路径补写 Bronze 层(RawEvent + DataLineage)
- 新增 _events_record_id: 上游 id 缺失时用 (league:home:away:date) 合成稳定幂等键
- 新增 _write_events_bronze: best-effort 写 RawEvent(幂等) + Lineage(matches/events_ingest)
- ingest 插入与变更更新后触发;同批 seen 集合防重复;基础设施失败只 warning
- TDD: 6 测试(插入/合成键/幂等跳过/变更更新/无变化不写/失败不拖垮),双变异验证通过
2026-09-21 19:23:37 +08:00
shangfangjian f6c0145c32 Merge pull request 'fix: 死信表真正接线 + 前端 HTTP 收敛与状态修正' (#9) from fix-deadletter-frontend-cleanup into main
Reviewed-on: #9
2026-09-21 18:39:08 +08:00
WorkBuddy b6a251407d fix(pipeline): 死信表真正接线 + 前端 HTTP 收敛与状态修正
- bzzoiro events/standings/stats 抓取失败写入 IngestFailure 死信(尽力而为,
  写入失败不影响主流程);顺带修复 standings 失败路径 league_r 缺 errors 键
  的 KeyError —— 该路径此前从未跑通,一旦失败会顶掉原始异常
- admin/api.ts 收敛为 lib/http.ts 薄门面,消除第二套 HTTP 实现;
  UNAUTHORIZED_EVENT 定义移至共享层,断开 lib→admin 反向依赖
- STATUS_META 死键 live 改为 in_play(对齐 normalize.py 口径),
  补 paused/postponed/cancelled/suspended;移除无人消费的
  DashboardStats.total_matches(items.length 近似,上限 100)
- 新增 tests/test_ingest_deadletter.py(6 例,变异验证判别力)
2026-09-21 18:36:43 +08:00
shangfangjian b5f7e682f0 Merge pull request 'fix: 批量修复一些问题
Reviewed-on: #8
2026-09-21 18:04:54 +08:00
shangfangjian 06e973d34d fix(concurrency): 消除 model 覆盖的模块级变量中转(并发串味)
R4 首版实现用模块级 _ACTIVE_MODEL_OVERRIDE 中转 model override,
理由是不想改动 run_specialists 的签名(既有测试会 mock 它)。

但 backtest.py:225 会 asyncio.gather 并发 8 场预测(Semaphore(8)),
每场都调用 predict_match_multi —— 模块级变量会被并发调用互相覆盖,
导致 A 场的预测用上 B 场的模型。这是静默的正确性缺陷。

改为: run_specialists 新增 model_override 形参,一路显式下传;
删除模块级变量。同步更新 6 处 mock 签名。

新增 3 个守卫并做变异验证:
  - test_r4_no_module_level_model_override_global (源码级,可判别)
  - test_r4_run_specialists_accepts_model_override_parameter
  - test_r4_dispatch_passes_override_to_specialists
变异测试: 重新引入全局变量方案 → 两个守卫变红;还原 → 全绿。

注: 曾尝试写并发行为测试,但真实 run_specialists 会访问数据库,
测试环境下不稳定(ConnectionRefusedError),会是 flaky 的假证据,
故改用清晰的源码级判别守卫,并在注释中说明原因。

240 passed / 1 skipped; tsc 0 error; vite build 成功。
2026-09-21 17:52:45 +08:00
shangfangjian 50a6753ff8 test: 修正 selectin 守卫的假阳性(变异测试发现)
原断言对「关系声明到下一注解」的整段(含注释)做 lazy="selectin"
子串匹配;而 Match.league 声明正下方的注释里恰好也写着该字样,
导致移除真实 kwarg 后测试依旧通过(变异测试确认)。

改为只匹配 relationship(...) 括号内内容,并加注释说明该假阳性。
验证: 干净代码通过 → 移除 league 的 lazy 后失败 → 还原后又通过。
全套 238 passed / 1 skipped。
2026-09-21 17:36:07 +08:00
shangfangjian 951faf31df test: 修复 13 个腐化用例,套件恢复全绿且顺序无关
按「测试腐化(a)/ 源码缺陷(b)/ 测试污染(c)」逐项定性,仅改 tests/:

- 外机绝对路径(4): 迁移文件路径改为相对仓库根解析(沿用
  test_regressions._read 约定),断言内容保持不变。
- cutoff 用例(4+1): mock 未生效的真因是 _agent_provider 被换成同步
  lambda,await 抛 TypeError 被生产代码吞掉后 IndexError;改为 async
  mock 并按 run_specialists/load_match_header/_agent_provider 的真实契约
  打补丁。degraded 用例再加 _upsert_prediction 顶层 kwargs(model)采集。
- 陈旧断言(3): agent 键按现契约断言中文映射;h2h mock 改 async;
  Match.stats 按设计为 lazy="select",从 MATCH_RELATIONS 移出并单独
  固化该设计决定。
- P0-3 守卫(1): seg 越界扫到下游 stats 管线导致误报,改为按缩进收口;
  合法形状含经 raw 派生变量中转的写法,并补元测试确保守卫仍能抓到回归。
- 交叉污染(6): test_multi_agent_cutoff 用 patch.object 精确还原,消除
  裸赋值泄漏的同步 mock;现已验证顺序无关。
2026-09-21 17:31:36 +08:00
shangfangjian 11da4d3631 fix(critical): 修复评审确认的 R1-R5 五处缺陷
R1 429 key 轮换路径调用不存在的 _km → NameError(且位于凭证脱敏日志行):
   改用 src/data/key_ring._mask,全树不再有 _km 调用。
   [注: 该改动已随另一工作流提交 9ccda4b 一并入库]
R2 ingest_bzzoiro_standings 被截断(return 前无 upsert 逻辑,standings 表
   永不写入、total_upserted 恒为 0):从 f05dc1a 移植完整实现,按
   (league_id, season, team_id) upsert,保留逐联赛错误隔离,不加 db.commit()。
   [注: 同上,已随 9ccda4b 入库]
R3 src/llm/agents/orchestrator.py 完成日志把 list 喂给 %d → logging TypeError:
   改为 len(ok_reports),与同文件降级日志行写法一致。
R4 mode=multi 静默丢弃调用方传入的 model:predict_match_multi 新增
   model 关键字参数,经 _ACTIVE_MODEL_OVERRIDE 下传至 _agent_provider,
   显式 model 优先级最高;override 生效时跳过 provider 缓存读写以免串味,
   并在 predict.py 派发点透传。
R5 回测把字符串日期直接与 timestamptz 列比较:新增 _parse_date_bound 助手,
   支持 YYYY-MM-DD / 完整 ISO / datetime / None,裸日期按 UTC 锚定,
   结束日取当天末刻(闭区间,避免最后一天被静默排除),非法输入抛 ValueError。

新增 tests/test_review_required_fixes.py 覆盖 R1-R5(R2/R4 为行为测试),
20 项全通过。
2026-09-21 17:19:24 +08:00
shangfangjian b9d7b6423e test(c2): 补齐导入次序回归,注明环状导入为预期且可自愈
test_bzzoiro_source_registry.py 增至 11 项:
- 新增 TestImportOrderSelfHeal:用独立子进程验证两种真实导入次序
  (sources 先 / bzzoiro 先)下注册表均可用。该缺陷只在真实模块初始化
  时序下成立,mock 照不出来,故不 mock。
- 新增未知名字仍抛 ValueError 的契约守卫。

src/data/sources.py:补全 _load_sources 文档,说明首次装载失败属正常的
环状导入(bzzoiro 仍在初始化),失败时保持 _loaded=False 使后续
get_source()/list_sources() 调用可重试装载(自愈)。

验证:
- tests/test_bzzoiro_source_registry.py:11 passed
- 两种导入次序实测均输出 bzzoiro ['bzzoiro']
2026-09-21 17:13:57 +08:00
shangfangjian 9ccda4bf51 fix(critical): 恢复 bzzoiro events 采集管线,消除数据源注册表静默失效
C2 数据丢失修复。重构提交 6a49940 删除了 src/data/bzzoiro.py 中的
fetch_bzzoiro_events 与 BzzoiroSource 后未做替换,同时 sources.py 用
`try/except Exception: pass` 吞掉了 ImportError,导致:

  - _SOURCES 注册表恒为空
  - get_source("bzzoiro") 恒抛 ValueError("未知数据源: bzzoiro")
  - src/api/routes/ingest.py 与 schedules.py 运行时全线失效,
    且日志中无任何导入错误痕迹

修复内容:
1. 从 f05dc1a 恢复并移植 events 管线:
   - fetch_bzzoiro_events():分页抓取 /events/(纯异步,复用现有 _fetch_json_async)
   - @register class BzzoiroSource + ingest():保留原签名
     (db, *, leagues, date_from, date_to, status),保持「不 commit,
     事务由调用方 UnitOfWork 控制」的契约,并保留单联赛抓取失败的
     错误隔离(记录 error 后 continue)
   - 复用现有 _to_date / _to_int_or_none / _match_key,未重复定义
   - 血缘字段继续使用与 nm 配对的 raw(避免 P0-3 回归)
2. sources.py:导入失败改为 logger.exception 记录,新增 _loaded 标记
   保证 _load_sources() 至多执行一次;失败时不置位以便后续重试。
   get_source() 对真正未知的名字仍抛 ValueError。

验证:
   - 新增 tests/test_bzzoiro_source_registry.py(8 项):注册表非空、
     get_source/list_sources 可用、ingest 签名契约、不再静默吞异常
   - test_regressions.py:3 failed -> 2 failed
   - 全量:14 failed -> 13 failed,通过数 190 -> 199

注意事项:未改动任何 caller(routes/ingest.py、routes/schedules.py 原样
可用,证明签名保持正确)。
2026-09-21 17:10:17 +08:00
shangfangjian 8c87b9d024 fix(critical): 修复调度器静默失效、数据源缺失与前端类型错误
C1 定时任务静默失效(4 层缺陷):
  - scheduler.py: croniter(expr, datetime.now) 未调用 now(),
    导致 TypeError 被吞、next_run 恒为 None(最深一层)
  - scheduler.py: _calc_next 非法 cron 静默吞异常 -> 改为构造期抛 ValueError
  - scheduler.py: _run_loop 一次异常即永久停摆 -> 增加异常隔离
  - scheduler.py: sleep(min(wait,60)) 使运行期 cron 变更最长 60s 才生效
    -> 改为固定 SLEEP_TICK 轮询
  - app.py / schedules.py: 3 处 register() 第 2 参误传 task 类型名

C3 EvalPage.tsx: 引用未导入的 EmptyState -> 改为已导入的 EmptyText
  (tsc --noEmit 由 1 error 变为 0 error)

新增 tests/test_scheduler_registration.py: 9 项行为回归测试
2026-09-21 17:04:04 +08:00
Profeto Agent f04e0df9e8 预测结果展示增强:status 徽章 + 专家权重条形图
- types.ts: 添加 agent_weights / status 字段
- components.tsx: 添加 AgentWeightsBar 纯 CSS 权重分布条 + EmptyText 组件
- Predictions.tsx: 摘要行 degraded/failed 状态徽章、展开区 AgentWeightsBar、
  预测中防连点、中文错误映射
- EvalPage.tsx: 修复 EmptyText 导入

构建验证:tsc && vite build 通过
2026-09-21 05:56:03 +00:00
shangfangjianandnew-provider/LongCat-2.0 < 6a4994097a feat: 采集管线基础设施全面接线
后端:
- bzzoiro events 采集成功后写入 RawEvent(Bronze 层)
- 采集失败写入 IngestFailure(死信队列)
- 写入 DataLineage(ETL 血缘追踪)
- stats 回填成功后写入 RawEvent + DataLineage
- 新增 DataQualityScheduler(每小时自动质量检查)
- 新增 /api/v1/admin/data-quality 端点
- 新增 /api/v1/admin/ingest-failures 端点(失败记录 + 重试)
- 新增 /api/v1/admin/llm/ping 端点(LLM 连通性测试)
- lifespan 启动 quality_scheduler
- Match 表补充 CHECK 约束(完赛必须有比分、状态枚举、半场≤全场)
- Prediction FK 加 ondelete="CASCADE" 与迁移对齐
- PredictRequest mode 加 pattern 校验

前端:
- 新增「数据管线」管理页(/admin/data-pipeline)
- 采集失败记录列表(状态/重试次数/错误类型 + 重试按钮)
- 数据质量检查结果(通过/未通过/严重度)
- 手动触发质量检查按钮
- 预测历史:比赛信息内嵌(日期/队名/主客徽标/赛果自动填充)
- 导航统一为 React Router Link
- AdminStats 类型扩展(matches/stats/standings 真实计数)

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
2026-09-21 10:28:02 +08:00
shangfangjianandnew-provider/LongCat-2.0 < ae89d0f04f feat: 数据采集管线基础设施接线 + 数据管线管理页
后端:
- bzzoiro 采集成功后写入 RawEvent(Bronze 层原始事件存档)
- 采集失败写入 IngestFailure(死信队列,支持重试)
- 写入 DataLineage(ETL 血缘追踪)
- 新增 DataQualityScheduler(每小时自动质量检查)
- 新增 /api/v1/admin/data-quality 端点(质量检查 + 手动触发)
- 新增 /api/v1/admin/ingest-failures 端点(失败记录 + 重试)
- 新增 /admin/llm/ping 端点(LLM 连通性测试,不依赖比赛)
- lifespan 启动 quality_scheduler

前端:
- 新增「数据管线」管理页(/admin/data-pipeline)
- 采集失败记录列表(状态/重试次数/错误类型)
- 数据质量检查结果(通过/未通过/严重度)
- 手动触发质量检查按钮
- 失败记录重试按钮
- 预测历史:比赛信息内嵌(日期/队名/主客徽标/赛果自动填充)
- 导航统一为 React Router Link
- AdminStats 类型扩展(matches/stats/standings 真实计数)

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
2026-09-21 10:04:26 +08:00
shangfangjianandnew-provider/LongCat-2.0 < b6e367a640 Code Review 第二轮修复:前端 HTTP 统一 + 数据采集可靠性
F1: 统一前端 HTTP 客户端
- 新增 frontend/src/lib/http.ts(共享客户端,统一超时/错误/401 处理)
- Matches.tsx 的列表/分页/进行中/预测请求改用 http 客户端
- 公开站与 Admin 行为一致

D2: events 采集按联赛分批提交
- _run_bzzoiro 改为 per-league 独立事务,避免超长事务
- 单联赛失败不影响其他联赛

F3: 仪表盘真实计数
- admin_stats 补充 matches/stats/standings 真实聚合
- Dashboard 展示比赛总数/已完赛/统计行/积分榜

F4: LLM 连通性测试不依赖 match_id=1
- 新增 POST /admin/llm/ping 端点(只发一次 chat,不依赖比赛)
- testLLMConnection 优先调用 ping,失败回退旧方式

F5: 导航改用 React Router Link
- App.tsx 中 <a href> 全部替换为 <Link to>

D5: 删除 away_possession 死代码
- bzzoiro.py 中移除计算与注释

D1: 管线基础设施表文档化
- RawEvent/IngestFailure/DataQualityCheck/DataLineage 添加「预留未启用」注释

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
2026-09-21 03:06:52 +08:00
shangfangjianandnew-provider/LongCat-2.0 < 675e176603 fix: 修复 Code Review 发现的 4 个 High + 1 个 Medium 问题
High-1/2: baseline mode 违反 DB CHECK 约束
- ck_mode_enum 扩展为 ('single','multi','baseline')
- baseline 的 run_type 从 'baseline' 改为 'live'(符合现有约束)
- 新增迁移 0017_mode_baseline

High-3: 限流日志 NameError: ip 未定义
- deps.py:190 的 logger.warning 中 ip → client_ip

High-4: 生产 Cookie 缺少 Secure 标志
- auth.py 登录时根据 APP_ENV 设置 secure=True(生产)

Medium-5: 降级日志参数类型错误
- orchestrator.py:266 ok_reports(list) → len(ok_reports)(int)

附加: PredictRequest mode 字段加 pattern 校验,与 DB 约束同源

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
2026-09-21 02:44:32 +08:00
shangfangjian 0a6af0657a 预测历史:比赛信息内嵌到预测API + 实际比分自动填充
后端:
- PredictionOut 新增 match 字段(内嵌比赛信息)
- list_predictions 返回完整比赛数据(日期/队名/赛果/主客徽标)
- 新增 _match_dict() 辅助函数序列化比赛对象

前端:
- 移除独立的 fetchMatches 调用,直接使用 p.match
- 实际比分自动从比赛赛果填充
- 显示:比赛日(M/D) + 主客队徽标 + 队伍中文名
- 赛后一键结算(使用比赛实际赛果)

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
)
2026-09-21 01:54:30 +08:00
shangfangjian a9e85a5d62 修复定时任务更新接口:支持部分更新
PUT /schedules/{id} 原来要求完整 ScheduleIn(所有字段必传),
前端只传 {enabled} 时因缺少 task/cron 校验失败。
→ 新增 ScheduleUpdate schema(所有字段可选)
→ update_schedule 仅更新提供的字段
→ 使用最终值同步调度器

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
)
2026-09-21 01:45:19 +08:00
shangfangjianandnew-provider/LongCat-2.0 < 7a4e61ebc4 定时任务操作添加点击反馈和文案提示
- 启用/禁用/立即执行/删除按钮添加 loading 状态(Spinner)
- 操作成功后显示绿色提示(如「已启用任务 xxx」)
- 操作失败后显示红色错误提示
- 按钮禁用期间防止重复点击

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
2026-09-21 01:42:17 +08:00
shangfangjianandnew-provider/LongCat-2.0 < 0a5a14cbbb 采集任务支持手动触发 + 定时调度(cron)
后端:
- 新增 Scheduler + ScheduledTask 核心模块(croniter 解析 cron 表达式)
- 新增 Schedule 数据模型(alembic 0016)
- 新增 /api/v1/admin/schedules CRUD 接口
- lifespan 启动时注册默认定时任务并加载数据库配置
- 默认任务:每日比赛/积分榜/30分钟统计回填(默认禁用)

前端:
- 设置页新增「定时任务」管理区块
- 支持:新建/启用禁用/立即执行/删除定时任务
- 显示:任务ID、类型、cron表达式、上次执行时间/状态

依赖:新增 croniter>=2.0

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
2026-09-21 01:36:54 +08:00
shangfangjian 1fc799a5b0 预测历史:实际比分自动填充 + 显示比赛日/队伍/主客徽标
- 实际比分自动从比赛赛果填充,无需手动输入
- 比赛列显示:比赛日(M/D) + 主客队徽标 + 队伍中文名
- 状态分三类:待赛果(灰)/可结算(蓝)/已结算(绿)
- 一键结算按钮:仅当比赛有赛果且未结算时显示
- 结算后1X2自动判断命中(绿✓/红✗)
- 顶部新增统计概览:已结算数/命中数/1X2准确率

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
)
2026-09-21 01:21:05 +08:00
shangfangjian f08fef8a22 预测管理改为预测历史页面
- 删除 Predictions.tsx,新建 PredictionHistory.tsx
- 不再支持触发预测(从主站比赛列表操作)
- 支持查看所有预测记录(全部/待标注/已结算筛选)
- 支持标注实际比分:行内编辑 + 保存,自动判断1X2是否命中
- 展开查看详情:预测比分/置信度/终裁意见/专家意见
- 侧边栏更名为「预测历史」,图标改为 logs

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
)
2026-09-21 01:17:14 +08:00
shangfangjianandnew-provider/LongCat-2.0 < 857a21910b 侧边栏图标统一为线条SVG + hover品牌色过渡
- 新增 Icon 组件:8种线条风格 SVG 图标(collection/chart/target/repeat等)
- 统一 stroke-width:1.5,hover 加粗到 2
- hover 时颜色平滑过渡到品牌色(press red)
- 添加 .nav-icon 和 .nav-icon-wrap CSS 类
- 活跃/悬停态:opacity 0.5→1,stroke-width 1.5→2

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
2026-09-21 01:01:01 +08:00
shangfangjian ad184e1b82 修复定位按钮无反应 + 增强高亮效果
- scrollIntoView 改用 requestAnimationFrame 确保 DOM 已更新
- 滚动位置改为 block: 'start',更可靠
- 高亮增加背景色变化(bg-press-wash/40),更明显
- 高亮持续时间延长至 5 秒

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
)
2026-09-21 00:48:39 +08:00
shangfangjian 83cc24e54b 健康摘要与联赛卡片联动
- 问题项新增「定位」按钮,点击滚动到对应联赛卡片
- 被定位的联赛卡片高亮显示(ring + scale),3秒后自动消失
- 健康摘要 → 联赛详情形成闭环操作

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
)
2026-09-21 00:44:19 +08:00
shangfangjian 7025338226 移除活跃Tab下划线渐变动画
- 删除 .nav-tab-active CSS 及 ::after 伪元素
- 移除 NavLink 中的 nav-tab-active 类名
- 活跃Tab仅保留左侧彩色竖条 + 背景高亮

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
)
2026-09-21 00:38:06 +08:00
shangfangjian 4ae61a5683 回到顶部按钮:滚动淡入 + 平滑滚动
- 滚动超过 300px 后按钮优雅淡入(translate-y + opacity 过渡)
- 点击后平滑滚动到顶部(behavior: smooth)
- 首页和积分榜页均添加
- 按钮隐藏时 pointer-events-none 防止误触

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
)
2026-09-21 00:23:56 +08:00
shangfangjian a2dc2b07c5 Tab切换动画:横向轻扫+淡入 + 活跃Tab下划线渐变
- 活跃Tab下方添加墨色渐变下划线动画(从中心向两侧展开)
- 页面内容切换时横向轻扫+淡入效果(0.3s)
- 使用 key={location.pathname} 触发重新渲染动画

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
)
2026-09-21 00:19:31 +08:00
shangfangjian 930dfd184d 侧边栏恢复先知刊头,去掉ADMIN
- 顶部通栏不再显示先知,改为纯功能栏(面包屑/日期/状态/登出)
- 侧边栏顶部显示「先知」毛笔字
- 移除 ADMIN 标识

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
)
2026-09-20 23:47:44 +08:00
shangfangjian 9fb8e24be3 后台管理页面顶部改为通栏样式
- 顶部报眉通栏显示:先知(毛笔字) + 面包屑 + 日期 + 系统状态 + 登出
- 移动端汉堡按钮移至顶部通栏,用于切换侧边栏
- 侧边栏不再重复显示先知标题,仅保留 ADMIN 标识
- 布局从 sidebar+main 横向排列改为 header 通栏 + sidebar/main 下方排列

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
)
2026-09-20 23:44:21 +08:00
shangfangjian 81b1f48382 积分榜补全剩余分区翻译
新增:
- Champions League Qualification → 欧冠资格
- Qualification Playoffs → 资格附加赛

现已覆盖数据库中所有8种分区类型。

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
)
2026-09-20 23:34:52 +08:00
shangfangjianandnew-provider/LongCat-2.0 < 730bc0de00 积分榜:补全所有分区中文翻译 + 防止换行
新增分区翻译:
- Europa Conference League → 欧协杯
- Championship / Promotion → 升级区
- Promotion Group → 升级组
- Relegation Group → 降级组
- Playoffs → 附加赛
- Championship Playoffs → 升级附加赛
- Qualification → 资格赛

修复:zoneBadge 添加 whitespace-nowrap,4字以上不换行

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
2026-09-20 23:32:05 +08:00
shangfangjianandnew-provider/LongCat-2.0 < 078fc0c5dd 修正3队翻译为主流媒体通用译法
- Cardiff City: 加的夫城 → 加的夫
- Linfield FC: 林斯菲尔德 → 连菲尔德
- Shamrock Rovers: 沙姆罗克流浪者 → 沙姆罗克流浪

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
2026-09-20 23:26:16 +08:00
shangfangjianandnew-provider/LongCat-2.0 < 5cbfe84ae6 修复队名翻译缺失:补全规范化队名映射
问题:normalize()返回的规范化名(带变音)在TEAM_NAME_ZH中找不到对应中文。
修复:补全以下规范化队名映射:
- 德语:Bayern München, FC Köln, Union Berlin, Mainz 05,
  VfL Bochum, Werder Bremen
- 西语:Atlético Madrid, Leganés
- 法语:Olympique Marseille
- 诺维奇城 → 诺维奇(主流译法)

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
2026-09-20 23:22:07 +08:00
shangfangjian 05dd0a6dc4 积分榜分区标签英译中
- Champions League → 欧冠区
- Europa League → 欧联区
- Conference League / Conference League Qualification → 欧协杯
- Relegation → 降级区
- Relegation Playoffs → 降级附加赛

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
)
2026-09-20 23:11:23 +08:00
shangfangjian 95be59bc9f 去掉刊头下方副标题文案
- 首页移除「足球比分预测 · 五路专家 · 终裁汇总」
- 积分榜页移除「联赛积分榜 · 积分 / 净胜 / xG差 / 近期走势」
- 刊头仅保留「先知」毛笔字,更简洁

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
)
2026-09-20 23:01:13 +08:00
shangfangjian 8a8b71083e 毛笔字刊头加粗:笔触更粗更醒目
- 新增 .font-brush CSS 类:text-stroke + text-shadow 模拟粗笔触
- font-weight:700 叠加,无论 CDN 字体还是回退楷体都显粗
- 移动端 + PC 端刊头均应用

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
)
2026-09-20 22:57:29 +08:00
shangfangjian 9a21edf5c0 首页刊头改用毛体草书(刘建毛草)
- 引入国内可访问的 jsDelivr CDN 字体:Liu Jian Mao Cao(毛体草书)
- 通过 @font-face 注册,font-display:swap 避免阻塞
- 离线回退到系统楷体(KaiTi)

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
)
2026-09-20 22:33:09 +08:00
shangfangjian 130f41a31e 修复 CSS 加载超时:移除 Google Fonts 依赖
国内网络无法访问 Google Fonts,导致首页 CSS 加载超时(ERR_CONNECTION_TIMED_OUT)。
→ 移除 Google Fonts 外部字体链接
→ font-brush 回退到系统楷体(KaiTi / STKaiti),仍有书法韵味且零依赖

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
)
2026-09-20 22:25:03 +08:00
shangfangjianandnew-provider/LongCat-2.0 < d28203ac54 去掉首页和后台的 Profeto 字样
- 首页刊头仅保留「先知」毛笔字
- 后台侧边栏仅保留「先知」毛笔字
- 版本号简化为 v1.0

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
2026-09-20 22:20:53 +08:00
shangfangjian 4cda662d6e 首页「先知」刊头改用毛笔字艺术字体
- 引入 Google Fonts「Ma Shan Zheng」(马善政毛笔楷书)
- Tailwind 新增 font-brush 字体族
- 首页 + 积分榜页 + 后台侧边栏的「先知」均改为毛笔字
- 字号加大(text-5xl / text-6xl),去除 tracking,突出书法韵味

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
)
2026-09-20 22:18:05 +08:00
shangfangjian 9a65e81ab0 移动端比赛行布局放宽,保持比分绝对居中
- 水平内边距 px-1 → px-4
- 垂直内边距 py-3.5 → py-5
- 行间距 gap-2 → gap-3
- 主客队列间距 gap-2 → gap-4
- 队名字号 13px → text-sm, VS 字号 xs → text-sm
- 比分字号 lg → xl,更醒目
- 网格 grid-cols-[1fr_auto_1fr] 确保比分绝对居中

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
)
2026-09-20 22:12:16 +08:00
shangfangjian 53b638d282 修复移动端比赛行比分严格居中
从 flex + flex-1 改为 grid grid-cols-[1fr_auto_1fr]:
- 主队/客队各占 1fr,比分 auto 宽度居中
- 无论队名长短,比分始终在视觉中心
- 桌面端 sm:contents 恢复 grid 分列布局

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
)
2026-09-20 22:06:45 +08:00
shangfangjianandnew-provider/LongCat-2.0 < 41109402a3 修复移动端比赛行队伍和比分居中
移动端原布局:主队/比分/客队垂直堆叠,比分区域仅 12px 宽,导致不居中。
改为:flex 单行居中布局,gap-3 均匀间距,主队右对齐 + 客队左对齐 + 比分居中。
桌面端保持原有 grid 布局不变。

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
2026-09-20 22:04:46 +08:00
shangfangjianandnew-provider/LongCat-2.0 < aaf9d38056 修复统计回填 ExecutionOption 错误
ingest_bzzoiro_event_stats 中 .options(select(Match.stats)) 用法错误:
.options() 期望 loader option(如 selectinload),不能传 select() 对象。
→ 改用 selectinload(Match.stats) 并补充 import。

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
2026-09-20 21:33:32 +08:00
shangfangjianandnew-provider/LongCat-2.0 < fb27f6e2d2 UI/UX 全面改进:22项优化落地(报纸风前端 + 管理后台)
P0 严重问题:
- 采集任务进度反馈:Collection 页新增任务状态跟踪(运行中/完成/失败) + 轮询 ingest 状态
- 积分榜加载态:切换联赛显示 spinner + 禁用 tab 防重复点击
- 数据完整性可操作:问题列表每项加「去修复」按钮,跳转采集页并预填参数
- 面包屑导航:顶部显示 仪表盘 > 当前页

P1 重要问题:
- 侧边栏当前页指示器:1.5px 圆点 → 4px 左侧彩色竖条 + 背景高亮
- Key Ring 重置确认:点击「重置冷却」前弹窗确认
- 预测比赛选择器:只显示未开赛 + 联赛筛选 + 显示可预测数量
- 表格移动端溢出:评估页表格加 overflow-x-auto
- 设置页合并:数据源 + LLM + 系统配置 → 统一「设置」页
- 预测按钮去重:移动端/桌面端合并为一个响应式按钮

P2 体验优化:
- 日志滚动保持:自动滚动仅当用户未向上滚动时
- 评估结果预览:显示「共 N 组」
- ⌘K 命令面板:键盘导航跳转所有管理页面
- 专家意见折叠:默认折叠,可展开(已存在)
- 版本信息:侧边栏底部显示 v1.0
- 积分榜表格:min-w 防止挤压

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
2026-09-20 21:24:21 +08:00
shangfangjianandnew-provider/LongCat-2.0 < dd538d66d7 首页:去掉日期选择器,顶部增加「进行中」比赛区块
- 移除日期选择器(今日按钮 + date input + 清除按钮)及相关 date 状态
- 顶部新增「进行中 · 实时比分」独立区块:红色脉冲圆点 + 主客比分
- 进行中区块仅在默认「未开赛」视图显示,切换已完赛/全部时隐藏
- 进行中数据独立请求(status=in_play,limit 20),不影响主列表逻辑
- 清理 todayStr(),改用 dateKey()统一日期格式化

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
2026-09-20 20:57:27 +08:00
shangfangjianandnew-provider/LongCat-2.0 < 9d7f8dfeee 简化首页加载按钮:合并「显示后续」与「载入更多」为单一智能按钮
- 3 天视图且有折叠比赛时 →「显示后续 N 场未开赛」(展开)
- 展开全部后 / 非 3 天视图 →「载入更多赛程」(从服务器拉取下一页)
- 移除重复按钮,保留「收起」文字链接回 3 天视图

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
2026-09-20 20:52:25 +08:00
shangfangjianandnew-provider/LongCat-2.0 < acac856b61 修复积分榜入库 UniqueViolationError
根因:ingest_bzzoiro_standings 批量处理时逐行 SELECT 检查 Standing 是否存在,
但新 INSERT 的 Standing 未 flush 就被同批下一条 SELECT 判定为不存在,
导致同一 (league_id, season, team_id) 插入两次 → UniqueViolation。

修复:
- 预加载该 league+season 的所有已有 standings team_id 到内存 set
- 同批内按 team.id 去重,同一 team 仅处理一次
- 命中已有 standing 时 UPDATE,否则 INSERT 并立即加入 set 防止同批重复

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
2026-09-20 20:35:53 +08:00
shangfangjianandnew-provider/LongCat-2.0 < 19f17145cc feat: 首页未开赛默认展示未来 3 天,支持展开更多
- 新增 showAllUpcoming 状态,默认 false,仅渲染未来 3 天(今/明/后天)的未开赛
- 列表头显示「未来3天 N / 共 M 场」计数
- 「显示后续 N 场未开赛」按钮:展开全部已加载的未开赛
- 展开后提供「收起,仅显示未来 3 天」回退
- 切换联赛/状态时自动重置为 3 天视图
- 空态区分「未来3天暂无比赛」与「暂无赛程」
- 已完赛/全部状态不受影响,展示全部

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
2026-09-20 20:29:12 +08:00
shangfangjianandnew-provider/LongCat-2.0 < c586a38b3a 修复积分榜"body stream already read"错误
两个根因:
1. src/api/routes/standings 端点使用 func.max() 但未导入 func → NameError(500)
   → 在 matches.py 的 sqlalchemy 导入中加入 func
2. 前端 api.ts 错误处理中先调 res.json() 失败后回退 res text(),
   但 Response body 流只能读一次 → "body stream already read"
   → 改为先 res.text() 再 JSON.parse(),避免重复读取

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
2026-09-20 20:05:40 +08:00
shangfangjianandnew-provider/LongCat-2.0 < f52ec8b963 feat: bzzoiro 多 API Key 轮换,遇限流自动切换
新增 KeyRing 轮换环(src/data/key_ring.py):
- 支持逗号/分号/换行分隔多个 key,单 key 场景零开销
- 遇到 429 自动标记当前 key 冷却(默认 60s)并立即切换到下一个 key
- 全部 key 冷却时等待最早恢复的 key,避免无谓重试
- 热更新 key 列表(增删 key 无需重启)
- 进程级单例,按 base URL 隔离

后端集成:
- bzzoiro._fetch_json_async 接入 KeyRing,429 立即轮换(不等待)
- 新增 /admin/keyring/status 端点展示 key 环状态
- 新增 /admin/keyring/cooldown/reset 紧急重置冷却
- BZZOIRO_KEY 配置描述提示多 key 支持
- mask_value 多 key 显示数量(如 "3 个 key(末段 …XXXX)")
- 清理已移除 injuries 的 API_FOOTBALL_KEY 配置项

前端:
- 数据源页新增 Key Ring 状态面板(每个 key 可用/冷却状态 + 重置按钮)
- 配置项脱敏展示支持多 key 计数

测试: 新增 24 个 KeyRing 单元测试 + 2 个 bzzoiro 轮换集成测试,全部通过。

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
2026-09-20 19:40:20 +08:00
shangfangjianandnew-provider/LongCat-2.0 < f05dc1ae15 refactor: 以 bzzoiro 为唯一数据源的全面重构
数据源统一为 bzzoiro,移除 Understat 与 injuries:
- 删除 src/data/understat.py / injuries.py 及相关测试
- 删除 injuries 模型与表;扩展 match_stats(xG 之外增加 big_chances/fouls)
- 新增 standings 表(联赛积分榜:位置/积分/xG/走势/分区)
- matches 表增加 source_event_id 血缘列,支撑统计回填

采集管线(bzzoiro 三条管线):
- events:赛程/比分(/events/),记录 source_event_id
- standings:积分榜快照(/leagues/{id}/standings/)
- stats:已完赛比赛详细统计回填(/events/{id}/stats/)

预测增强:
- standings_slice 替代 injuries_slice;积分榜专家替代阵容完整性专家
- AGENT_META runtime_config 同步更新

管理后台:
- ingest 路由重写为单一 bzzoiro 入口 + task 参数(events/standings/stats/all)
- 新增 /admin/data-completeness 数据完整性分析 API
- 数据源状态页简化为 bzzoiro 单源

前端:
- 采集页重构为任务驱动(比赛/积分榜/统计回填/全量)
- 新增「数据完整性」可视化页(覆盖率矩阵/字段完整率/健康摘要)
- 新增主站积分榜页(/standings)与比赛详情完整统计面板
- agent 名称同步更新(injuries→standings)

迁移 0015_bzzoiro_single_source 已在容器内验证通过,后端测试全部通过。

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
2026-09-20 19:13:07 +08:00
137 changed files with 12803 additions and 5034 deletions
-1
View File
@@ -40,7 +40,6 @@ LLM_TIMEOUT=60
# ---- 数据源 ----
BZZOIRO_KEY=
API_FOOTBALL_KEY=
# ---- CORS ----
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
+74 -35
View File
@@ -12,28 +12,30 @@
│ REST API
┌──────────────────────▼──────────────────────────────┐
│ FastAPI │
│ ├── /api/v1/matches 比赛查询
│ ├── /api/v1/matches 比赛查询(公开只读)
│ ├── /api/v1/predict LLM 预测 (单/多 Agent) │
│ ├── /api/v1/ingest/* 数据采集
│ ├── /api/v1/eval/* 评估回填
│ └── /api/v1/backtest 回测
│ ├── /api/v1/ingest/* 数据采集(需管理员)
│ ├── /api/v1/eval/* 评估回填(需管理员)
│ └── /api/v1/backtest 回测(需管理员)
└──────────┬─────────────────────────────┬────────────┘
│ │
┌──────────▼──────────┐ ┌─────────────▼────────────┐
│ PostgreSQL │ │ LLM (OpenAI-compatible) │
6 张表 │ │ OpenAI / Deepseek / │
12 张表 │ │ OpenAI / Deepseek / │
│ leagues/teams/ │ │ Ollama / 任意网关 │
│ matches/match_ │ └──────────────────────────┘
│ stats/predictions/
injuries
│ stats/standings/
predictions/
│ app_settings/ │
│ schedules + │
│ raw_events 等 4 张 │
│ 数据治理表 │
└─────────────────────┘
│ 采集
┌──────────┴─────────────────────────────────────────┐
│ 数据源 (DataSource 协议 + 注册表) │
── bzzoiro 比分 / 统计 / xG
│ ├── understat xG 回填 │
│ └── injuries 伤停数据 (api-football) │
── bzzoiro 比分 / 赛程 / 统计 / 积分榜
└────────────────────────────────────────────────────┘
```
@@ -55,7 +57,7 @@ API Route → Application Service → Repository → UnitOfWork → DB
比赛数据 → 切片 ─┬─→ A 近期状态专家 ─┐
├─→ B 攻防数据专家 ─┤
├─→ C 主客因素专家 ─┼─→ 终裁专家 ─→ 最终预测
├─→ D 阵容完整专家 ─┤
├─→ D 联赛排名专家 ─┤
└─→ E 历史交锋专家 ─┘
```
@@ -66,8 +68,7 @@ API Route → Application Service → Repository → UnitOfWork → DB
### 数据正确性保障
- **Cutoff 机制**: 回测时只使用 `cutoff_at` 之前已采集的数据
- **Injury 防泄漏**: 伤停查询强制 `retrieved_at <= cutoff`
- **Cutoff 机制**: 回测时只使用 `cutoff_at` 之前已采集的数据(近况/交锋/统计/积分榜切片统一生效)
- **LLM 输出校验**: Pydantic 严格校验 + 语义一致性检查
- **数据库约束**: CHECK 约束作为最后一道防线
@@ -130,6 +131,21 @@ cd frontend && npm install && npm run dev
后端运行在 `http://localhost:8000`,前端在 `http://localhost:5173`
## 生产上线检查清单
公网部署前逐项确认(第 1–4 项由启动校验强制,不满足拒绝启动;详见 [docs/06-deployment.md](docs/06-deployment.md#生产上线检查清单)):
- [ ] `APP_ENV=production`(安全校验 / Cookie `Secure` / 管理端点 fail-closed 的总开关)
- [ ] `SECRET_KEY` 强随机:`openssl rand -base64 32`,禁止弱值
- [ ] `ADMIN_PASSWORD``ADMIN_API_KEY` 至少配置其一
- [ ] 数据库强密码,禁止 `football:football` 等示例弱密码
- [ ] HTTPS(反代终结 TLS;production 下会话 Cookie 自动 `Secure`)
- [ ] 反代后设 `TRUST_PROXY_HEADERS=True`,仅可信反代可达 API,并配置 `X-Forwarded-For` / `X-Real-IP`
- [ ] 限流前置到 Nginx `limit_req`;应用内限流与 KeyRing 仅单进程有效,多 worker 会放大配额
- [ ] uvicorn 单 worker(默认);需扩容先网关统一限流再起多实例
- [ ] 启动后验证 `/health``/health/ready` 均 200
- [ ] 数据库迁移已内置:compose/Dockerfile 启动即执行 `alembic upgrade head`
## 安全与限流
- `/api/v1/predict`: 内存滑动窗口限流(10 次/分钟/IP),多 worker 时每进程独立计数
@@ -141,20 +157,29 @@ cd frontend && npm install && npm run dev
## API 概览
**公开只读**(无需登录;`predict` 带内存限流):
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/api/v1/matches` | 比赛查询(筛选/分页) |
| GET | `/api/v1/leagues` | 联赛列表 |
| POST | `/api/v1/predict` | LLM 预测 (`mode=single`/`multi`) |
| GET | `/api/v1/predictions` | 预测历史 |
| POST | `/api/v1/ingest/bzzoiro` | 采集比分/统计 |
| POST | `/api/v1/ingest/understat` | 回填 xG |
| POST | `/api/v1/ingest/injuries` | 采集伤停 |
| GET | `/api/v1/leagues` | 联赛列表(仅 id/code/name/country) |
| GET | `/api/v1/matches` | 比赛查询(筛选/游标分页) |
| GET | `/api/v1/matches/{id}` | 比赛详情(含统计与最近预测) |
| GET | `/api/v1/matches/{id}/context` | 比赛上下文(双方近况 + 历史交锋) |
| GET | `/api/v1/standings` | 联赛积分榜 |
| POST | `/api/v1/predict` | LLM 预测 (`mode=single`/`multi`/`baseline`) |
| GET | `/health``/health/ready` | 存活 / 就绪检查(含 DB) |
**需管理员**(Cookie 会话或 `X-API-Key`):
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | `/api/v1/ingest/bzzoiro` | 采集赛果/赛程/统计/积分榜 |
| GET | `/api/v1/predictions` | 预测历史(列表) |
| GET | `/api/v1/predictions/{id}` | 单条预测详情 |
| POST | `/api/v1/eval/settle` | 回填实际结果 |
| GET | `/api/v1/eval/summary` | 准确率汇总 |
| POST | `/api/v1/backtest` | 历史回测 |
| GET | `/health` | 存活检查 |
| GET | `/health/ready` | 就绪检查(含 DB) |
| `/api/v1/admin/**` | 配置/采集状态/日志/定时任务/死信等 | 管理后台(router 级鉴权) |
## 项目结构
@@ -163,36 +188,46 @@ Profeto/
├── src/
│ ├── api/ # FastAPI 路由层
│ │ ├── app.py # 应用工厂 + lifespan
│ │ ├── deps.py # 依赖注入:鉴权 / 限流
│ │ ├── schemas.py # Pydantic 请求/响应模型
│ │ └── routes/
│ │ ├── matches.py # 比赛查询
│ │ ├── predict.py # 预测入口
│ │ ├── ingest.py # 数据采集
│ │ ├── eval.py # 评估回填
│ │ ── backtest.py # 回测
│ │ ├── matches.py # 比赛查询(公开只读)
│ │ ├── predict.py # 预测入口 + 预测历史
│ │ ├── ingest.py # 数据采集(需管理员)
│ │ ├── eval.py # 评估回填(需管理员)
│ │ ── backtest.py # 回测(需管理员)
│ │ ├── auth.py # 登录/登出/改密
│ │ ├── admin_settings.py # /admin/** 配置/日志/数据质量(router 级鉴权)
│ │ └── schedules.py # 定时任务 + 死信重试(router 级鉴权)
│ ├── core/ # 基础设施
│ │ ├── config.py # pydantic-settings 配置
│ │ ├── crypto.py # 加密/哈希
│ │ ├── http_client.py # 共享 httpx 客户端
│ │ ── retry.py # 重试工具(指数退避)
│ │ ── log_buffer.py # 内存日志缓冲(admin 日志页)
│ │ ├── runtime_config.py # DB 配置覆盖(.env → app_settings)
│ │ ├── scheduler.py # 进程内 cron 调度器
│ │ └── security_check.py # 启动安全校验
│ ├── data/ # 数据层
│ │ ├── sources.py # DataSource 协议 + 注册表
│ │ ├── bzzoiro.py # bzzoiro 数据源(events/standings/stats)
│ │ ├── normalize.py # 数据规范化契约
│ │ ├── bzzoiro.py # bzzoiro 数据源
│ │ ├── understat.py # understat xG 数据源
│ │ ├── injuries.py # 伤停数据
│ │ ├── config.py # 联赛映射常量
│ │ ── team_names.py # 队名归一化
│ │ ── key_ring.py # API Key 轮换环(429 冷却)
│ │ ├── team_names.py # 队名归一化
│ │ └── team_names_zh.py # 队名中文映射
│ ├── db/ # 数据库
│ │ ├── base.py # SQLAlchemy async engine
│ │ ├── models.py # ORM 模型 (6 表)
│ │ ├── models.py # ORM 模型 (12 表)
│ │ ├── unit_of_work.py # UnitOfWork 事务封装
│ │ └── repositories.py # Repository 数据访问
│ └── llm/ # LLM 预测核心
│ ├── predict.py # 预测服务 (缓存 + 单/多模式)
│ ├── predict.py # 预测服务 (缓存 + 单/多/基线模式)
│ ├── context_builder.py # 数据切片 + 上下文拼接
│ ├── baseline.py # 基线预测(均值模型)
│ ├── eval.py # 评估统计
│ ├── backtest.py # 回测框架
│ ├── provider.py # 多提供商 LLM 抽象
│ ├── utils.py # LLM 工具函数
│ ├── validation.py # LLM 输出校验
│ ├── agents/
│ │ ├── base.py # Agent 基础设施 + 解析
@@ -200,6 +235,11 @@ Profeto/
│ └── prompts/ # Prompt 模板
├── alembic/ # 数据库迁移
├── frontend/ # React 前端
│ └── src/
│ ├── pages/ # 公开站(赛程 Matches + 积分榜 Standings)
│ ├── admin/ # 管理后台(布局/页面/数据访问层 dal.ts)
│ ├── components/ # 共享组件
│ └── lib/http.ts # 唯一 HTTP 实现(带凭据/超时/错误处理)
├── docs/ # 详细文档
├── tests/ # 单元测试
├── docker-compose.yml
@@ -234,7 +274,6 @@ Profeto/
| `LLM_SPECIALIST_MODEL` | 专家模型 (空=回落 LLM_MODEL) | |
| `LLM_AGGREGATOR_MODEL` | 终裁模型 (空=回落 LLM_MODEL) | |
| `BZZOIRO_KEY` | bzzoiro API Key | *(必填)* |
| `API_FOOTBALL_KEY` | api-football Key (伤停) | |
| `CORS_ORIGINS` | 允许的跨域来源 | `http://localhost:5173` |
## 测试
@@ -0,0 +1,102 @@
"""bzzoiro 单一数据源重构:新增 standings、扩展 match_stats、删除 injuries
Revision ID: 0015_bzzoiro_single_source
Revises: 0014_predictions_agent_weights
Create Date: 2026-09-20
变更内容:
1. 新增 standings 表(联赛积分榜快照,来源 bzzoiro /leagues/{id}/standings/)
2. match_stats 新增 bzzoiro /events/{id}/stats/ 扩展字段:
home/away_big_chances, home/away_fouls
3. 删除 injuries 表(数据源已下线,不再采集伤停)
4. Understat 无独立表(xG 写入 match_stats),无需删表;
历史 source='understat' 数据保留,不再新增。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '0015_bzzoiro_single_source'
down_revision: Union[str, None] = '0014_predictions_agent_weights'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 0. matches 增加数据血缘列(bzzoiro 上游事件 ID)
op.add_column('matches', sa.Column('source_event_id', sa.BigInteger()))
op.create_index('ix_matches_source_event_id', 'matches', ['source_event_id'])
# 1. standings 表
op.create_table(
'standings',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('league_id', sa.Integer(), sa.ForeignKey('leagues.id'), nullable=False),
sa.Column('season', sa.String(12), nullable=False),
sa.Column('team_id', sa.Integer(), sa.ForeignKey('teams.id'), nullable=False),
sa.Column('position', sa.Integer(), nullable=False),
sa.Column('played', sa.Integer(), nullable=False, server_default='0'),
sa.Column('won', sa.Integer(), nullable=False, server_default='0'),
sa.Column('drawn', sa.Integer(), nullable=False, server_default='0'),
sa.Column('lost', sa.Integer(), nullable=False, server_default='0'),
sa.Column('goals_for', sa.Integer(), nullable=False, server_default='0'),
sa.Column('goals_against', sa.Integer(), nullable=False, server_default='0'),
sa.Column('goal_diff', sa.Integer(), nullable=False, server_default='0'),
sa.Column('points', sa.Integer(), nullable=False, server_default='0'),
sa.Column('xg_for', sa.Float()),
sa.Column('xg_against', sa.Float()),
sa.Column('form', sa.String(20)),
sa.Column('zone', sa.String(50)),
sa.Column('updated_at', sa.DateTime(timezone=True)),
sa.Column('retrieved_at', sa.DateTime(timezone=True)),
sa.UniqueConstraint('league_id', 'season', 'team_id', name='uq_standings_league_season_team'),
)
op.create_index(
'ix_standings_league_season_pos', 'standings',
['league_id', 'season', 'position'],
)
# 2. match_stats 扩展字段
op.add_column('match_stats', sa.Column('home_big_chances', sa.Integer()))
op.add_column('match_stats', sa.Column('away_big_chances', sa.Integer()))
op.add_column('match_stats', sa.Column('home_fouls', sa.Integer()))
op.add_column('match_stats', sa.Column('away_fouls', sa.Integer()))
# 3. 删除 injuries 表
op.drop_index('ix_injuries_player_fixture', table_name='injuries')
op.drop_index('ix_injuries_team_date', table_name='injuries')
op.drop_table('injuries')
def downgrade() -> None:
# 恢复 injuries 表(不含数据)
op.create_table(
'injuries',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('player_id', sa.Integer()),
sa.Column('player_name', sa.String(120), nullable=False),
sa.Column('team_id', sa.Integer(), sa.ForeignKey('teams.id')),
sa.Column('fixture_id', sa.Integer()),
sa.Column('league_id', sa.Integer()),
sa.Column('injury_type', sa.String(50)),
sa.Column('reason', sa.String(200)),
sa.Column('injury_date', sa.Date()),
sa.Column('return_date', sa.Date()),
sa.Column('retrieved_at', sa.DateTime(timezone=True)),
)
op.create_index('ix_injuries_player_fixture', 'injuries', ['player_id', 'fixture_id', 'injury_type'])
op.create_index('ix_injuries_team_date', 'injuries', ['team_id', 'injury_date'])
op.drop_index('ix_matches_source_event_id', table_name='matches')
op.drop_column('matches', 'source_event_id')
op.drop_column('match_stats', 'away_fouls')
op.drop_column('match_stats', 'home_fouls')
op.drop_column('match_stats', 'away_big_chances')
op.drop_column('match_stats', 'home_big_chances')
op.drop_index('ix_standings_league_season_pos', table_name='standings')
op.drop_table('standings')
+38
View File
@@ -0,0 +1,38 @@
"""新增 schedules 表
Revision ID: 0016_schedules
Revises: 0015_bzzoiro_single_source
Create Date: 2026-09-21
定时采集任务配置表。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '0016_schedules'
down_revision: Union[str, None] = '0015_bzzoiro_single_source'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'schedules',
sa.Column('id', sa.String(50), primary_key=True),
sa.Column('task', sa.String(20), nullable=False),
sa.Column('cron', sa.String(100), nullable=False),
sa.Column('leagues', sa.Text()),
sa.Column('enabled', sa.Boolean(), server_default='true'),
sa.Column('last_run_at', sa.DateTime(timezone=True)),
sa.Column('last_status', sa.String(20)),
sa.Column('created_at', sa.DateTime(timezone=True)),
sa.Column('updated_at', sa.DateTime(timezone=True)),
)
def downgrade() -> None:
op.drop_table('schedules')
+39
View File
@@ -0,0 +1,39 @@
"""扩展 ck_mode_enum 约束支持 baseline 模式
Revision ID: 0017_mode_baseline
Revises: 0016_schedules
Create Date: 2026-09-21
Code Review High-1/2 修复:
业务支持 mode='baseline'(非 LLM 统计基线),但 DB CHECK 约束只允许
('single', 'multi'),导致 baseline 预测写入时 CheckViolation。
run_type='baseline' 改为 'live'(代码侧),约束无需扩展。
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '0017_mode_baseline'
down_revision: Union[str, None] = '0016_schedules'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.drop_constraint('ck_mode_enum', 'predictions', type_='check')
op.create_check_constraint(
'ck_mode_enum', 'predictions',
"mode IN ('single', 'multi', 'baseline')",
)
def downgrade() -> None:
# 先清理 baseline 数据再恢复旧约束
op.execute("DELETE FROM predictions WHERE mode = 'baseline'")
op.drop_constraint('ck_mode_enum', 'predictions', type_='check')
op.create_check_constraint(
'ck_mode_enum', 'predictions',
"mode IN ('single', 'multi')",
)
+52
View File
@@ -0,0 +1,52 @@
"""Match 表补充 CHECK 约束:完赛必须有比分 + 状态枚举 + 半场≤全场
Revision ID: 0018_match_checks
Revises: 0017_mode_baseline
Create Date: 2026-09-21
Code Review DB-5:
- 已完赛比赛必须有比分(数据库级兜底)
- match_status 枚举约束
- 半场进球 ≤ 全场进球
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '0018_match_checks'
down_revision: Union[str, None] = '0017_mode_baseline'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 先清理可能违反新约束的数据
op.execute("UPDATE matches SET match_status = 'scheduled' WHERE match_status NOT IN ('finished', 'scheduled', 'in_play', 'paused', 'postponed', 'cancelled', 'suspended')")
op.execute("UPDATE matches SET home_goals = 0, away_goals = 0 WHERE match_status = 'finished' AND (home_goals IS NULL OR away_goals IS NULL)")
# 添加 CHECK 约束
op.create_check_constraint(
'ck_matches_finished_has_score', 'matches',
"match_status <> 'finished' OR (home_goals IS NOT NULL AND away_goals IS NOT NULL)",
)
op.create_check_constraint(
'ck_matches_status_enum', 'matches',
"match_status IN ('finished', 'scheduled', 'in_play', 'paused', 'postponed', 'cancelled', 'suspended')",
)
op.create_check_constraint(
'ck_matches_home_ht_le_full', 'matches',
"home_ht_goals IS NULL OR home_goals IS NULL OR home_ht_goals <= home_goals",
)
op.create_check_constraint(
'ck_matches_away_ht_le_full', 'matches',
"away_ht_goals IS NULL OR away_goals IS NULL OR away_ht_goals <= away_goals",
)
def downgrade() -> None:
op.drop_constraint('ck_matches_away_ht_le_full', 'matches', type_='check')
op.drop_constraint('ck_matches_home_ht_le_full', 'matches', type_='check')
op.drop_constraint('ck_matches_status_enum', 'matches', type_='check')
op.drop_constraint('ck_matches_finished_has_score', 'matches', type_='check')
+42
View File
@@ -0,0 +1,42 @@
"""采集任务状态表 ingest_jobs
Revision ID: 0019_ingest_jobs
Revises: 0018_match_checks
Create Date: 2026-09-22
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '0019_ingest_jobs'
down_revision: Union[str, None] = '0018_match_checks'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'ingest_jobs',
sa.Column('id', sa.String(36), primary_key=True),
sa.Column('task', sa.String(20), nullable=False),
sa.Column('params', sa.JSON(), nullable=False, server_default='{}'),
sa.Column('status', sa.String(20), nullable=False, server_default='pending'),
sa.Column('result', sa.JSON(), nullable=True),
sa.Column('error', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column('started_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('finished_at', sa.DateTime(timezone=True), nullable=True),
)
op.create_index('ix_ingest_job_status_created', 'ingest_jobs', ['status', 'created_at'])
op.create_check_constraint(
'ck_ingest_job_status', 'ingest_jobs',
"status IN ('pending', 'running', 'success', 'failed')",
)
def downgrade() -> None:
op.drop_constraint('ck_ingest_job_status', 'ingest_jobs', type_='check')
op.drop_index('ix_ingest_job_status_created', table_name='ingest_jobs')
op.drop_table('ingest_jobs')
+32
View File
@@ -0,0 +1,32 @@
"""球队别名表 team_aliases
Revision ID: 0020_team_aliases
Revises: 0019_ingest_jobs
Create Date: 2026-09-22
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '0020_team_aliases'
down_revision: Union[str, None] = '0019_ingest_jobs'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'team_aliases',
sa.Column('alias_normalized', sa.String(120), primary_key=True),
sa.Column('team_id', sa.Integer, sa.ForeignKey('teams.id', ondelete='CASCADE'), nullable=False),
sa.Column('original_alias', sa.String(120), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index('ix_team_aliases_team_id', 'team_aliases', ['team_id'])
def downgrade() -> None:
op.drop_index('ix_team_aliases_team_id', table_name='team_aliases')
op.drop_table('team_aliases')
@@ -0,0 +1,38 @@
"""matches.source_event_id 部分唯一索引
业务唯一键:同联赛同主客同自然天一条(ix_matches_unique,既有)。
source_event_id 是上游 bzzoiro 的比赛 id,用于统计回填与血缘追踪;
当它非空时应全局唯一(同一 upstream 比赛只对应一行 matches),
避免同一场比赛因自然键天级舍入差异产生重复。
partial unique(WHERE source_event_id IS NOT NULL):
- 兼容存量空 source_event_id 的历史行(不强制回填);
- 新采集行均带 source_event_id,从此具备 upstream 唯一性。
Revision ID: 0021_match_source_event_id_unique
Revises: 0020_team_aliases
Create Date: 2026-09-22
"""
from typing import Sequence, Union
from alembic import op
revision: str = '0021_match_source_event_id_unique'
down_revision: Union[str, None] = '0020_team_aliases'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_index(
'ix_matches_source_event_id_unique',
'matches',
['source_event_id'],
unique=True,
postgresql_where=op.text('source_event_id IS NOT NULL'),
)
def downgrade() -> None:
op.drop_index('ix_matches_source_event_id_unique', table_name='matches')
@@ -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,58 @@
"""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')
op.drop_index('ix_standings_leason_season_pos', table_name='standings')
op.create_index('ix_standings_league_season_pos', '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=True,
)
# 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'],
)
+36 -28
View File
@@ -13,16 +13,15 @@
│ │
│ 数据查询 预测编排 采集(手动/cron 触发) │
│ ┌──────┐ ┌────────────┐ ┌───────────────────┐ │
│ │matches│ │ orchestrator│ │ bzzoiro (赛果) │ │
│ │matches│ │ orchestrator│ │ bzzoiro (唯一源) │ │
│ │leagues│ │ ┌─ 5 专家并行(便宜模型) │ │
│ └──┬───┘ │ │ h2h / form / stats / │ │
│ │ │ │ home_away / injuries │ │
│ │ │ │ home_away / standings │ │
│ │ │ └─ aggregator 终裁(强模型) │ │
│ │ └────────────┘ └───────────────────┘
│ │ └ understat (xG)
──────────────────┐ └ injuries (伤停)
│ PostgreSQL (6 张表) │ httpx → 外部 API │
│ └────────────────────┘ │
│ │ └────────────┘ │ events / standings │
┌──┴──────────────┴──┐ │ /stats 三条管线 │
│ PostgreSQL (12 张表)│ └───────────────────┘
└────────────────────┘ httpx → 外部 API │
└─────────────────────────────────────────────────────┘
```
@@ -31,7 +30,7 @@
1. `POST /predict {match_id}` → orchestrator
2. `load_match_header`: 查比赛 + 双方 + 联赛(一次 eager load)
3. **5 个专家 agent 并行**(`asyncio.gather`),每个:
- 各自的数据切片函数查库(近况/交锋/积分榜 SQL 聚合/伤停/xG)
- 各自的数据切片函数查库(近况/交锋/积分榜聚合/射门控球/xG)
- 切片无数据 → **跳过 LLM**,直接 `no_data` stub(省 token、防幻觉)
- 有数据 → 专属 prompt(专家模型,便宜快)→ 结构化 JSON 报告(`home_edge` 方向性评分 + 证据)
4. **终裁 agent**:5 份报告 + 比赛信息 → 权衡采信度(`agent_weights`)→ 最终预测 JSON
@@ -44,7 +43,7 @@
|---|---|
| **多专家并行而非单次大 prompt** | 每维度独立迭代 prompt;报告可归因(哪个维度分析错了);总延迟 ≈ 2 次串行调用 |
| **专家/终裁模型分档** | 专家用便宜模型快速分析,终裁用强模型汇总决策,成本与质量平衡(`LLM_SPECIALIST_MODEL` / `LLM_AGGREGATOR_MODEL`) |
| **no_data 门控** | 无数据维度(如伤停未接入)不调 LLM,终裁知道维度缺失,不编造 |
| **no_data 门控** | 无数据维度(如积分榜未采集)不调 LLM,终裁知道维度缺失,不编造 |
| **fail-open** | 单个专家失败只标记 `status=error`,其余照常;研究场景可用性优先 |
| **`match_date_date` 天级去重** | 不同源时间精度不同,秒级匹配会产生重复行;天级 + 数据库唯一约束 |
| **积分榜 SQL 聚合 + season 过滤** | `UNION ALL` 主客双视角 + `GROUP BY` 在库内算,只算当前赛季(修复过跨赛季 bug) |
@@ -57,33 +56,38 @@
Profeto/
├── src/
│ ├── api/
│ │ ├── app.py # FastAPI 工厂(lifespan 仅验证 DB 连接,不建表)
│ │ ├── deps.py # 依赖:管理接口鉴权(X-API-Key)
│ │ ├── app.py # FastAPI 工厂(lifespan:迁移校验/定时任务/生产限流提醒)
│ │ ├── deps.py # 依赖:管理接口鉴权(Cookie/X-API-Key)+ 限流
│ │ ├── schemas.py # Pydantic v2 请求/响应
│ │ └── routes/
│ │ ├── matches.py # 联赛/比赛查询(游标分页)
│ │ ├── predict.py # 预测 + 预测历史
│ │ ├── ingest.py # 采集触发(自管 session,需鉴权)
│ │ ├── eval.py # 赛后回填 + 准确率汇总
│ │ ── backtest.py # 历史回测(需鉴权)
│ │ ├── matches.py # 联赛/比赛/上下文/积分榜(公开只读)
│ │ ├── predict.py # 预测(限流)+ 预测历史(需鉴权)
│ │ ├── ingest.py # 采集触发(需鉴权)
│ │ ├── eval.py # 赛后回填 + 准确率汇总(需鉴权)
│ │ ── backtest.py # 历史回测(需鉴权)
│ │ ├── auth.py # 登录/登出/改密
│ │ ├── admin_settings.py # /admin/** 配置/日志/数据质量(router 级鉴权)
│ │ └── schedules.py # 定时任务 + 死信重试(router 级鉴权)
│ ├── db/
│ │ ├── base.py # async engine + get_db/get_db_read
│ │ ├── models.py # 6 张表 ORM
│ │ ├── models.py # 12 张表 ORM
│ │ ├── repositories.py # 仓储层
│ │ └── unit_of_work.py # 事务边界
│ ├── data/
│ │ ├── bzzoiro.py # 赛果采集 + 幂等入库
│ │ ├── understat.py # xG 回填
│ │ ├── injuries.py # 伤停采集(带文件缓存)
│ │ ├── bzzoiro.py # 唯一数据源:events/standings/stats 三管线 + Bronze 层
│ │ ├── normalize.py # NormalizedMatch 清洗契约
│ │ ├── team_names.py # 队名归一映射
│ │ ├── team_names_zh.py # 队名中文名映射
│ │ ├── key_ring.py # 多 key 轮换(429 冷却,进程内)
│ │ ├── sources.py # 数据源注册表
│ │ └── config.py # 联赛代码映射
│ ├── llm/
│ │ ├── provider.py # OpenAI-compatible 抽象(共享连接池/JSON 兜底解析)
│ │ ├── context_builder.py # 数据切片(h2h/form/stats/home_away/injuries)+ 单 agent 拼接
│ │ ├── context_builder.py # 数据切片(h2h/form/stats/home_away/standings)+ 单 agent 拼接
│ │ ├── predict.py # 预测入口(mode 分派 + 缓存)
│ │ ├── baseline.py # 基线预测(均值模型,mode=baseline)
│ │ ├── eval.py # 准确率统计
│ │ ├── utils.py # LLM 工具函数
│ │ ├── validation.py # LLM 输出严格校验(Pydantic)
│ │ ├── backtest.py # 回测执行
│ │ ├── agents/
@@ -91,16 +95,20 @@ Profeto/
│ │ │ └── orchestrator.py # 并行专家 → 终裁 → 存库
│ │ └── prompts/
│ │ ├── match_prediction_v1/v2.md # 单 agent 模板
│ │ └── agents/{h2h,form,stats,home_away,injuries,aggregator}_v1.md
│ │ └── agents/{form,stats,home_away,standings,h2h,aggregator}_v1.md
│ └── core/
│ ├── config.py # pydantic-settings
│ ├── crypto.py # 加密/哈希
│ ├── http_client.py # 共享 httpx 客户端
── retry.py # 重试工具
├── alembic/versions/ # 0001~0006(0001 建表 → 0006 漂移清理)
├── frontend/src/pages/Matches.tsx # 单页(预测面板 + 专家报告折叠区 + 游标分页)
├── tests/ # 核心 + agent 测试
── log_buffer.py # 内存日志缓冲(admin 日志页)
│ ├── runtime_config.py # DB 配置覆盖(app_settings)
│ ├── scheduler.py # 进程内 cron 调度器
│ └── security_check.py # 启动安全校验
├── alembic/versions/ # 0001~0018(建表 → Bronze 层 → 单一数据源 → 基线模式等)
├── frontend/src/ # pages/(公开站) + admin/(管理后台) + lib/http.ts(唯一 HTTP 实现)
├── tests/ # 核心 + agent 测试(250+ 项,自包含)
├── docker-compose.yml # api + postgres 两容器
└── docs/ # 本文档
└── docs/ # 本文档
```
## 技术栈
@@ -113,5 +121,5 @@ Profeto/
| HTTP | httpx(共享连接池)/ urllib(bzzoiro 同步限速) |
| LLM | OpenAI-compatible 接口(openai/deepseek/ollama 等任一) |
| 前端 | Vite + React 18 + TypeScript + Tailwind |
| 测试 | pytest + pytest-asyncio(33 项,自包含) |
| 测试 | pytest + pytest-asyncio(250+ 项,自包含) |
| 部署 | Docker Compose(api + postgres) |
+13 -4
View File
@@ -66,6 +66,9 @@ curl -X POST http://localhost:8000/api/v1/ingest/bzzoiro \
-d '{"leagues":["E0"],"date_from":"2026-08-01","date_to":"2026-09-08"}'
```
> 注:采集/评估端点需管理员凭据。本地开发环境(未配置鉴权、非 production)默认放行;
> 生产环境需先 `POST /auth/login` 取 Cookie,或带 `X-API-Key` 头。
数据量大时**直接拉整赛季**(约 380 场,含近几个赛季更好,近况/交锋/积分榜都需要历史):
```bash
@@ -74,14 +77,20 @@ curl -X POST http://localhost:8000/api/v1/ingest/bzzoiro \
-d '{"leagues":["E0"],"date_from":"2025-08-01","date_to":"2026-09-08"}'
```
### 回填 xG(可选,让攻防数据 agent 有数据)
### 回填积分榜与统计(让攻防/排名专家有数据)
```bash
curl -X POST http://localhost:8000/api/v1/ingest/understat \
curl -X POST http://localhost:8000/api/v1/ingest/bzzoiro \
-H "Content-Type: application/json" \
-d '{"league":"E0","season":2025}'
-d '{"task":"standings"}'
curl -X POST http://localhost:8000/api/v1/ingest/bzzoiro \
-H "Content-Type: application/json" \
-d '{"task":"stats","leagues":["E0"],"limit":300}'
```
`task=stats` 只补空字段(xG/射门/控球等),不创建比赛。
### 查比赛
浏览器打开 http://localhost:5173 ,选"英超 / 未开赛";
@@ -126,4 +135,4 @@ curl http://localhost:8000/api/v1/eval/summary
| predict 返回 502 | 看 uvicorn 日志的 LLM error;确认 `LLM_BASE_URL`/`LLM_API_KEY`;`response_format` 不兼容的网关会报错(改用支持 json mode 的模型) |
| 采集 0 场 | bzzoiro Key 失效或联赛代码写错;先 `GET /api/v1/leagues` 看库里有没有联赛 |
| 专家报告全是 no_data | 历史数据不够 —— 近况需要每队近 5 场、积分榜需要本赛季已完赛比赛,多拉几周数据 |
| xg agent 报无 xG 数据 | 先跑 understat 回填;注意 understat 只有五大联赛 |
| stats 专家报无 xG/统计 | 先跑 `task=stats` 回填(bzzoiro 统计管线,只补空字段) |
+80 -34
View File
@@ -4,18 +4,30 @@ Base URL: `http://localhost:8000` · 交互式文档: `/docs`(Swagger)与 `/redo
所有数据端点返回 JSON。错误统一为 `{"detail": "<message>"}` + 对应 HTTP 状态码。
## 鉴权模型
| 级别 | 端点 | 说明 |
|---|---|---|
| **公开只读** | `GET /leagues``GET /matches``GET /matches/{id}``GET /matches/{id}/context``GET /standings``GET /health*` | 无需任何凭据;公开站直接调用 |
| **公开 + 限流** | `POST /predict` | 内存滑动窗口限流(10 次/分钟/IP) |
| **需管理员** | `GET /predictions*``POST /ingest/bzzoiro``/eval/*``POST /backtest``/admin/**` | Cookie 会话(`POST /auth/login` 颁发)或 `X-API-Key` 头 |
管理端点在生产环境未配置鉴权时 fail-closed(503),不会静默放行。
---
## 数据查询
## 数据查询(公开只读)
### `GET /api/v1/leagues`
列出已入库联赛。
列出已入库联赛(P1-3: 公开站联赛筛选动态加载来源)
```json
[{"id": 1, "code": "E0", "name": "Premier League", "country": "England"}]
```
仅返回 `id/code/name/country` 四个展示字段,不含任何配置或密钥信息。
### `GET /api/v1/matches`
比赛列表,游标分页。
@@ -47,7 +59,44 @@ Base URL: `http://localhost:8000` · 交互式文档: `/docs`(Swagger)与 `/redo
### `GET /api/v1/matches/{id}`
单场比赛详情,字段同上。
单场比赛详情,字段同上,另含 `stats`(统计)与 `recent_predictions`(最近 5 条预测摘要)
### `GET /api/v1/matches/{id}/context`
比赛上下文(公开只读,P1-2: 公开站详情页「近况/交锋」数据来源;不触发 LLM):
- `home_recent`: 主队最近 5 场已完赛
- `away_recent`: 客队最近 5 场已完赛
- `h2h`: 双方最近 5 次交手
```json
{
"home_recent": [
{"match_date": "2026-09-12T14:00:00+00:00", "home_team": "阿森纳",
"away_team": "切尔西", "home_goals": 2, "away_goals": 1}
],
"away_recent": [],
"h2h": []
}
```
数据不足时对应列表为空(前端展示空态)。比赛不存在返回 404。
### `GET /api/v1/standings`
联赛积分榜(公开只读)。参数:`league`(联赛代码,空 = 全部)、`season`(空 = 各联赛最新赛季)。
```json
{"leagues": [
{"league_code": "E0", "league_name": "Premier League", "season": "2026-2027",
"retrieved_at": "2026-09-20T08:00:00+00:00",
"rows": [{"position": 1, "team": "阿森纳", "team_en": "Arsenal",
"played": 5, "won": 4, "drawn": 1, "lost": 0,
"goals_for": 11, "goals_against": 3, "goal_diff": 8,
"points": 13, "xg_for": 9.8, "xg_against": 3.9,
"form": "WWWDW", "zone": "UEFA Champions League"}]}
]}
```
---
@@ -96,13 +145,13 @@ Base URL: `http://localhost:8000` · 交互式文档: `/docs`(Swagger)与 `/redo
"exp_home_goals": null, "exp_away_goals": null, "probable_score": null,
"model": "gpt-4o-mini", "latency_ms": 2100,
"prompt_tokens": 380, "completion_tokens": 120},
{"agent": "injuries", "status": "no_data", "data_sufficiency": "none",
{"agent": "standings", "status": "no_data", "data_sufficiency": "none",
"analysis": "该维度无数据,跳过分析。", "home_edge": null, "subjective_confidence": null,
"key_evidence": [], "exp_home_goals": null, "exp_away_goals": null,
"probable_score": null, "model": "", "latency_ms": null,
"prompt_tokens": null, "completion_tokens": null}
],
"agent_weights": {"form": 0.9, "stats": 0.8, "home_away": 0.7, "injuries": 0.0, "h2h": 0.8},
"agent_weights": {"form": 0.9, "stats": 0.8, "home_away": 0.7, "standings": 0.8, "h2h": 0.8},
"context": "[5 份报告的 JSON 串]",
"latency_ms": 9800
}
@@ -112,11 +161,11 @@ Base URL: `http://localhost:8000` · 交互式文档: `/docs`(Swagger)与 `/redo
错误:404 比赛不存在;502 LLM 调用失败(终裁失败时整体失败,专家失败不会)。
### `GET /api/v1/predictions?match_id=&limit=`
### `GET /api/v1/predictions?match_id=&limit=`(需管理员)
预测历史(倒序),含 `settled` 与实际比分回填状态。
### `GET /api/v1/predictions/{id}`
### `GET /api/v1/predictions/{id}`(需管理员)
单条预测详情(含完整 `agent_outputs`)。
@@ -124,40 +173,34 @@ Base URL: `http://localhost:8000` · 交互式文档: `/docs`(Swagger)与 `/redo
## 数据采集
### `POST /api/v1/ingest/bzzoiro`
### `POST /api/v1/ingest/bzzoiro`(需管理员)
从 bzzoiro 采集赛果/赛程并入库(幂等,重复跑安全)。
从 bzzoiro(唯一数据源)采集数据并入库(幂等,重复跑安全)。任务在后台异步执行,请求立即返回。
```json
{"leagues": ["E0", "SP1"], "date_from": "2025-08-01", "date_to": "2026-09-08", "status": "finished"}
{"task": "all", "leagues": ["E0", "SP1"], "date_from": "2025-08-01", "date_to": "2026-09-08", "status": "finished"}
```
- `status` 还可传 `scheduled` 拉未来赛程
| 字段 | 默认 | 说明 |
|---|---|---|
| `task` | `events` | 采集任务:`events`(比赛)/ `standings`(积分榜)/ `stats`(统计回填,含 xG)/ `all` |
| `leagues` | 全部已知联赛 | 联赛代码列表,如 `["E0", "SP1"]` |
| `date_from` / `date_to` | 空 | 日期范围(`YYYY-MM-DD`) |
| `status` | 空(两者都采) | `finished`(已完赛)/ `scheduled`(未来赛程) |
| `season` | 当前赛季 | standings 赛季,如 `"2026-2027"` |
| `limit` | 100 | stats 回填单次最大比赛数(1500) |
- 响应含每联赛 `inserted`/`updated`/`errors` 统计
- `task=stats` 只补空字段、不创建比赛(xG/射门/控球等统计回填)
### `POST /api/v1/ingest/understat`
回填 xG(只补空字段,不创建比赛):
```json
{"league": "E0", "season": 2025}
```
`season=2025` 表示 2025-2026 赛季。仅支持五大联赛。
### `POST /api/v1/ingest/injuries`
采集伤停(需 `API_FOOTBALL_KEY`,当前只返回计数,尚未接入 context):
```json
{"date": "2026-09-10"}
```
> 历史版本曾有独立的 understat(xG)与 injuries(伤停)采集端点,
> 已随数据源收敛为 bzzoiro 唯一来源而移除。
---
## 评估
### `POST /api/v1/eval/settle`
### `POST /api/v1/eval/settle`(需管理员)
赛后回填实际比分:
@@ -165,7 +208,7 @@ Base URL: `http://localhost:8000` · 交互式文档: `/docs`(Swagger)与 `/redo
{"prediction_id": 7, "home_goals": 2, "away_goals": 1}
```
### `GET /api/v1/eval/summary`
### `GET /api/v1/eval/summary`(需管理员)
`provider × model` 聚合已结算预测:
@@ -183,7 +226,10 @@ Base URL: `http://localhost:8000` · 交互式文档: `/docs`(Swagger)与 `/redo
## 基础
| 端点 | 说明 |
|---|---|
| `GET /health` | 存活检查 |
| `GET /docs` | Swagger UI |
| 端点 | 权限 | 说明 |
|---|---|---|
| `GET /health` | 公开 | 存活检查 |
| `GET /docs``GET /redoc` | 公开 | Swagger UI / ReDoc |
| `POST /auth/login``POST /auth/logout``GET /auth/me` | 公开 | 管理员 Cookie 会话登录/登出/当前用户 |
| `POST /auth/change-password` | 需管理员 | 修改管理员密码 |
| `POST /backtest` | 需管理员 | 历史回测(对已完赛比赛批量预测并评估) |
+7 -7
View File
@@ -10,7 +10,7 @@ Profeto 的核心预测路径是 **5 个领域专家 Agent 并行分析 + 1 个
| `form` 近期状态 | 分析比分与关键事件,判断近期走势 | 两队近 N 场赛果(含 xG) | `home_edge` + 走势判断 |
| `stats` 攻防数据 | 评估进球、射门与控球,量化攻防强度 | 近 N 场进球/射门/控球/xG 统计 | `home_edge` + 攻防强度 |
| `home_away` 主客因素 | 对比主场与客场表现,评估地理优势影响 | 主队主场战绩 + 客队客场战绩 | `home_edge` + 地理优势 |
| `injuries` 阵容完整性 | 汇总伤停与停赛名单,评估战力缺失程度 | 伤停数据(当前无源 → no_data 门控) | `home_edge``no_data` |
| `standings` 联赛排名 | 结合积分榜排名、积分与分区(欧冠/欧联/降级),评估双方竞争位置 | 两队当前赛季积分榜行(排名/积分/分区/近期战绩) | `home_edge``no_data` |
| `h2h` 历史交锋 | 分析过去数年以及近期的交手数据,提取交手规律 | 近 N 次交锋(含主客方向 + 总计统计) | `home_edge` + 交手规律 |
| `aggregator` 终裁 | 权衡 5 份报告 → 最终结论 | 5 份结构化报告 + 比赛头信息 | 最终预测 + 各报告采信度 |
@@ -25,7 +25,7 @@ POST /predict {match_id, mode: "multi"}
│ ├─ form agent ─┐
│ ├─ stats agent │ 每个 agent 拿到专属数据切片
│ ├─ home_away agent │ → no_data 门控 → 调 LLM → 输出 JSON 报告
│ ├─ injuries agent │ (无数据 → 跳过 LLM,返回 stub)
│ ├─ standings agent │ (无数据 → 跳过 LLM,返回 stub)
│ └─ h2h agent ─┘
├─ aggregator agent(5 份报告 + 比赛头 → 最终 JSON)
@@ -39,12 +39,12 @@ POST /predict {match_id, mode: "multi"}
5 个专家通过 `asyncio.gather` 并发,总延迟 ≈ `max(专家延迟) + 终裁延迟` ≈ 2 次串行 LLM 调用。
### 2. no_data 门控(省 token、防幻觉)
数据切片为空时(如伤停数据源未接入),**跳过 LLM 调用**,直接返回:
数据切片为空时(如该场比赛的积分榜尚未采集),**跳过 LLM 调用**,直接返回:
```json
{"agent": "injuries", "status": "no_data", "data_sufficiency": "none",
{"agent": "standings", "status": "no_data", "data_sufficiency": "none",
"analysis": "该维度无数据,跳过分析。"}
```
终裁 Agent 会看到这个 `no_data` 状态,不会编造伤停分析。
终裁 Agent 会看到这个 `no_data` 状态,不会编造积分榜分析。
### 3. fail-open(单专家失败不阻断)
单个专家 LLM 调用失败 → 其报告标记 `status: error`,其余 4 份 + 终裁照常执行。
@@ -89,7 +89,7 @@ POST /predict {match_id, mode: "multi"}
"1x2": "1",
"subjective_confidence": 0.68,
"reasoning": "综合 stats 报告的攻防强度与 form 报告的三连胜势头……",
"agent_weights": {"form": 0.9, "stats": 0.8, "home_away": 0.7, "injuries": 0.0, "h2h": 0.8}
"agent_weights": {"form": 0.9, "stats": 0.8, "home_away": 0.7, "standings": 0.8, "h2h": 0.8}
}
```
@@ -120,7 +120,7 @@ agents/
├── form_v1.md # 近期状态专家
├── stats_v1.md # 攻防数据专家
├── home_away_v1.md # 主客因素专家
├── injuries_v1.md # 阵容完整性专家
├── standings_v1.md # 联赛排名专家
├── h2h_v1.md # 历史交锋专家
└── aggregator_v1.md # 终裁
```
+100 -18
View File
@@ -4,9 +4,10 @@
| 数据源 | 用途 | 必需 Key | 说明 |
|---|---|---|---|
| bzzoiro | 赛果/赛程(主源) | `BZZOIRO_KEY` | 五大联赛历史 + 实时 |
| understat | xG 回填 | 无(公开) | 仅五大联赛,补 `match_stats.xg` |
| api-football | 伤停 | `API_FOOTBALL_KEY` | 当前只采集计数,未接入 context |
| bzzoiro(唯一) | 赛果/赛程/积分榜/统计(xG、射门、控球等) | `BZZOIRO_KEY` | 五大联赛 + 欧战,历史 + 实时 |
> 历史版本曾有 understat(xG 回填)与 api-football(伤停)两个辅助源,
> 现已移除:数据源收敛为 bzzoiro 唯一来源,统计与积分榜均由 bzzoiro 管线采集。
### bzzoiro
@@ -28,12 +29,6 @@
> - 黄牌: `home_yellow_cards` / `away_yellow_cards`
> - 红牌: `home_red_cards` / `away_red_cards`
### understat
- 端点:`/getLeagueData/{league}/{season}`,返回 JS 包裹的 JSON(需正则提取)
- 只回填 xG(`match_stats.home_xg`/`away_xg`),**不创建新比赛**
- 通过"天级日期 + 队名归一"匹配已有比赛
## 数据清洗契约
所有数据源统一清洗为 `NormalizedMatch`(`src/data/normalize.py`),字段:
@@ -65,12 +60,68 @@
### 队名归一化
`src/data/team_names.py` 维护 `NORMALIZE_MAP`(如 `Man City` → `Manchester City`),未命中映射的队名原样返回。
`src/data.team_names.py` 维护 `NORMALIZE_MAP`(如 `Man City` → `Manchester City`),未命中映射的队名原样返回。
归一前先做 Unicode NFKD 去重音。
**唯一键是归一后英文名**:`teams.name` 带 `UNIQUE` 约束,所有入库路径均经 `TeamRepository.get_or_create` 收敛归一化
(events / standings 管线在调用前归一,仓库层再做一次幂等归一作为兜底)。创建新 Team 时打 `info` 日志记录「原始名 → 归一后名」。
> ⚠️ **`normalize` 当前大小写敏感**:仅当入参大小写与 `NORMALIZE_MAP` 键完全匹配时才触发映射
>(如 `"Man City"` → `"Manchester City"`,但 `"man city"` 原样保留)。上游 bzzoiro 返回的队名首字母大写,
>实际命中无问题;若新增数据源返回全小写/全大写队名,需先 `title()` 再归一,否则会绕过映射产生重复 Team。
#### 别名机制(`team_aliases`)
归一仍可能遗漏历史重复队(如 `"Bayern Munich"` 与 `"Bayern München"` 经 NFKD 后相同则命中,
但 `"Man United"` vs `"Manchester United"` 若漏映射)。`team_aliases` 表提供**显式别名→teams.id** 映射:
| 列 | 说明 |
|----|------|
| `alias_normalized` | PK,`normalize(别名)` 后的稳定幂等键 |
| `team_id` | FK → `teams.id`(ON DELETE CASCADE) |
| `original_alias` | 原始写法(保留供参考) |
**定位三步链**(`get_or_create`):`normalize(name)` → 查 `teams.name` → 查 `team_aliases`(以 `normalize(name)` 为 PK)→ 都没有才 insert 新 Team。别名命中即复用已有 Team,避免产生重复。
**添加别名**(不自动合并历史重复队):
- **Admin 接口**(推荐):`POST /api/v1/admin/teams/aliases {"alias": "Man United", "team_id": 42}`(require_admin,幂等)
- **直接 SQL**:
```sql
INSERT INTO team_aliases(alias_normalized, team_id, original_alias)
VALUES ('man united', 42, 'Man United')
ON CONFLICT (alias_normalized) DO UPDATE SET team_id = EXCLUDED.team_id, original_alias = EXCLUDED.original_alias;
```
> ⚠️ **别名不自动合并**:发现历史重复队 A/B 后,需人工确认归一目标(如保留 B),再为 A 的归一名添加别名指向 B。
> 合并前请确认 A 的 `matches`/`standings` 引用是否需要迁移(可先 `SELECT COUNT(*) FROM matches WHERE home_team_id = A.id OR away_team_id = A.id` 评估)。
**改名 / 合并流程**(人工):
当发现两个 `teams` 行实际是同一球队(如 `Manchester City` 与 `Man City` 因历史数据大小写差异各占一行):
1. 确定**保留行**(通常选归一后规范名、且被更多 Match 引用的那行)。
2. 将被删行的所有引用指向保留行(`UPDATE matches SET home_team_id = 保留id WHERE home_team_id = 删行id`,客场同理;
`standings` / `match_stats` 按 `team_id` 同理)。
3. 删掉多余行:`DELETE FROM teams WHERE id = 删行id`。
> 此过程引入外键约束风险,务必在事务中执行并先 `BEGIN; ... ` 验证行数后再 `COMMIT`。
> 暂不做自动合并(避免误合相似名),仅通过下方 Admin 接口列出「近似重名」候选,由人工判定。
## Admin:近似重名候选
`GET /api/v1/admin/team-name-duplicates` 只读列出启发式相似候选(大小写差异、子串包含、前缀碰撞),不做自动合并。
典型用途:定期巡检,发现候选后走上方人工 SQL 合并。启发式规则:
- **大小写变体**:`lower(name)` 相同但 `name` 不同(如 `Arsenal FC` / `arsenal fc`)。
- **子串包含**:A 是 B 的子串且长度 ≥ 5(如 `Manchester` / `Manchester City`)。
- **前缀碰撞**:前 8 个字符相同的两队。
命中任一规则即列为候选,按相似度分组返回。
## 数据库 Schema
6 张表:
12 张表:核心业务表 5 张见下方 DDL,其余 7 张(积分榜/配置/调度/治理)见后文表格。
```sql
-- 联赛
@@ -144,21 +195,48 @@ CREATE TABLE predictions (
reasoning TEXT,
raw_response JSONB, -- LLM 完整原始响应
agent_outputs JSONB, -- multi 模式: 5 份专家报告
agent_weights JSONB, -- multi 模式: 终裁给出的各专家权重
created_at TIMESTAMPTZ,
actual_home_goals INT, actual_away_goals INT, -- 赛后回填
settled BOOLEAN DEFAULT FALSE
);
```
其余 7 张表(DDL 略,详见 `src/db/models.py` 与 alembic 迁移):
| 表 | 状态 | 用途 |
|---|---|---|
| `standings` | 已启用 | 联赛积分榜追加快照(P0-02):每次采集 INSERT 新行(available_at=now),唯一键 `(league_id, season, team_id, available_at)`;查询取每队 available_at 最新快照,支持回测还原历史榜单。含排名/战绩/进失球/积分/分区(zone) |
| `app_settings` | 已启用 | 后台运行时设置(如数据源 API Key),读取时优先于 `.env` 默认值 |
| `schedules` | 已启用 | 定时采集任务配置(task/cron/leagues/enabled),供内置调度器执行 |
| `raw_events` | 预留未启用 | Bronze 层原始事件存档;规划中用于重放与审计 |
| `ingest_failures` | 已启用 | 采集失败死信:bzzoiro 三条管线(events/standings/stats)抓取失败时写入,admin 后台可查看与重试 |
| `data_quality_checks` | 预留未启用 | 数据质量检查结果;规划中定时检查比赛/统计/积分榜完整性 |
| `data_lineage` | 预留未启用 | ETL 血缘追踪;规划中记录源记录到目标表的映射 |
### 关键设计点
1. **`match_date_date`(天级日期)**: 用于天级去重。bzzoiro 返回的时间带时分秒,精确匹配不可靠,故拆出 `DATE` 列做唯一键。
2. **`ix_matches_unique`**: `(league_id, home_team_id, away_team_id, match_date_date)` 唯一,保证同一场比赛重复采集时 upsert 而非插入重复行。
2. **`ix_matches_unique`**: `(league_id, home_team_id, away_team_id, match_date_date)` 唯一,保证同一场比赛重复采集时 upsert 而非插入重复行。**业务唯一:同联赛同主客同自然天一条。**
3. **`predictions` 级联删除**: `ON DELETE CASCADE`,删比赛时自动清其预测。
3. **`source_event_id` 部分唯一**: `ix_matches_source_event_id_unique`(WHERE source_event_id IS NOT NULL)——上游 bzzoiro 的比赛 id,当非空时全局唯一。作用:
- 统计回填(`/events/{id}/stats/`)与 Bronze 血缘(/events/ 采集)通过它定位比赛,不依赖自然键天级舍入;
- 新采集行均带此 id,避免同一 upstream 比赛因时间戳差异绕开自然键产生重复。
- 存量空 source_event_id 历史行不受影响(不强制回填)。
4. **`mode` + `prompt_version`**: `single` 模式存 `v1`/`v2`,`multi` 模式存 `multi_v1`/`multi_v2`,eval summary 按这两列天然分组对比
4. **`predictions` 级联删除**: `ON DELETE CASCADE`,删比赛时自动清其预测
5. **`mode` + `prompt_version`**: `single` 模式存 `v1`/`v2`,`multi` 模式存 `multi_v1`/`multi_v2`,eval summary 按这两列天然分组对比。
## 采集 upsert 查找顺序
events 管线按以下优先级定位已有比赛,命中即复用(更新):
1. **`source_event_id`**(upstream event id,唯一索引命中)——最精确,跨自然键舍入差异;
2. **自然键**:`(league_id, home_team_id, away_team_id, match_date_date)`(内存去重,覆盖无 event id 的采集)。
两者都未命中 → insert 新比赛。
## 入库语义(幂等)
@@ -170,7 +248,9 @@ CREATE TABLE predictions (
- 状态:只允许单向升级(`scheduled` → `finished`),防止完赛行被覆盖成赛程
- stats:只补空(`home_xg` 已有值时不覆盖)
`ingest_understat` 只回填 xG(也只补空),不创建比赛。
`task=stats` 只回填统计(xG/射门/控球等,也只补空),不创建比赛。
`task=standings` 追加快照(available_at=now,ON CONFLICT DO NOTHING);公开接口与切片均取每队 available_at 最新快照,支持回测还原历史榜单。
## 采集建议
@@ -183,9 +263,11 @@ curl -X POST /api/v1/ingest/bzzoiro \
curl -X POST /api/v1/ingest/bzzoiro \
-d '{"leagues":["E0"],"date_from":"2026-09-01","date_to":"2026-09-08"}'
# 3. xG 回填(可选,提升 xg agent 质量)
curl -X POST /api/v1/ingest/understat -d '{"league":"E0","season":2025}'
curl -X POST /api/v1/ingest/understat -d '{"league":"E0","season":2026}'
# 3. 积分榜 + 统计回填(xG/射门/控球,提升 stats/standings 专家质量)
curl -X POST /api/v1/ingest/bzzoiro -d '{"task":"standings"}'
curl -X POST /api/v1/ingest/bzzoiro -d '{"task":"stats","leagues":["E0"],"limit":300}'
# 注:采集端点需管理员凭据(Cookie 会话或 X-API-Key 头),下同
```
建议用外部 cron(如系统 crontab)定时触发,不引入 worker/redis。
+39 -2
View File
@@ -33,6 +33,25 @@ curl http://localhost:8000/health
> **注意**: `api` 服务启动时会先执行 `alembic upgrade head` 迁移数据库,再启动 uvicorn。
> 容器内数据库连接自动使用 `postgres` 服务名(通过 compose `environment` 覆盖 `.env` 中的 `DB_HOST`)。
## 生产上线检查清单
公网上线前逐项勾选。第 14 项由 `src/core/security_check.py``APP_ENV=production`
启动时**强制校验**,不满足直接拒绝启动(开发环境仅告警);管理鉴权另有请求期 fail-closed(503)。
- [ ] **1. `APP_ENV=production`** — 安全校验、Cookie `Secure`、管理端点 fail-closed 均以它为总开关
- [ ] **2. `SECRET_KEY` 强随机** — 用 `openssl rand -base64 32` 生成;禁止弱值/短值(弱值黑名单会拒绝启动,含把生成指令原样粘进去的情况)
- [ ] **3. 管理鉴权至少其一**`ADMIN_PASSWORD``ADMIN_API_KEY`(后台改过密码后以数据库哈希优先);两者皆空时管理接口 503
- [ ] **4. 数据库强密码** — 禁止 `football:football` 等示例弱密码(启动校验会拒绝);compose 的 `POSTGRES_PASSWORD` 必填,缺失时容器拒绝启动
- [ ] **5. HTTPS** — 由反代(Nginx/Caddy)终结 TLS;`APP_ENV=production` 下会话 Cookie 自动 `Secure`(且 HttpOnly + SameSite=Lax)
- [ ] **6. 反代信任头**`TRUST_PROXY_HEADERS=True`,且**仅可信反代可达 API**;反代需设置 `X-Forwarded-For`(`$proxy_add_x_forwarded_for`)与 `X-Real-IP`,否则限流/日志按反代 IP 计数
- [ ] **7. 限流前置到网关** — 推荐 Nginx `limit_req`(配置见[安全与限流](#安全与限流));应用内限流与 KeyRing 为**单进程内存实现**,多 worker 各自独立计数会把实际配额放大 N 倍(启动时会打印一次性告警)
- [ ] **8. uvicorn 单 worker** — compose/Dockerfile 默认单 worker,保持即可;需横向扩容时先在网关统一限流,再起多实例(每实例仍单 worker)
> ⚠️ **多 worker 陷阱**:应用内限流(`_RateLimiter`)与 KeyRing 均为**进程内纯内存状态**,多 worker 部署(如 `uvicorn --workers 4`)时各进程**各自独立计数、互不共享**——实际限流配额会被放大 N 倍、KeyRing 限流状态也不同步。
> 若确需多 worker,必须前置 Nginx/网关做**全局限流**(见[安全与限流](#安全与限流)),并设环境变量 `STRICT_SINGLE_WORKER=True`(见下)在启动期强制拒绝多 worker,避免静默配额漂移。
- [ ] **9. 启动后健康检查**`curl /health` 返回 200(存活);`curl /health/ready` 返回 200(就绪,校验数据库连通,不可达时 503)
- [ ] **10. 数据库迁移** — compose/Dockerfile 启动命令已内置 `alembic upgrade head && uvicorn …`,升级镜像重启即自动迁移,无需手动执行
## 本地开发部署
```bash
@@ -81,13 +100,31 @@ cd frontend && npm install && npm run dev
| `LLM_TIMEOUT` | ❌ | `60` | 单次调用超时(秒) |
| `LLM_SPECIALIST_MODEL` | ❌ | — | 专家模型(回落 `LLM_MODEL`) |
| `LLM_AGGREGATOR_MODEL` | ❌ | — | 终裁模型(回落 `LLM_MODEL`) |
| `BZZOIRO_KEY` | ✅ | — | bzzoiro 数据源 Key |
| `API_FOOTBALL_KEY` | ❌ | — | 伤停数据源 Key |
| `BZZOIRO_KEY` | ✅ | — | bzzoiro 数据源 Key(唯一数据源) |
| `CORS_ORIGINS` | ❌ | `http://localhost:5173,...` | 允许的跨域来源 |
| `STRICT_SINGLE_WORKER` | ❌ | `False` | `True` 时若以多 worker 启动则拒绝(防限流配额漂移) |
| `SECRET_KEY` | ❌ | — | 加密主密钥(生产环境必填) |
| `ADMIN_PASSWORD` | ❌ | — | 管理后台密码(留空=不启用) |
| `ADMIN_API_KEY` | ❌ | — | 机器/脚本调用的 API Key |
## 同站部署 vs 跨站 CSRF
Profeto 管理鉴权使用 **HttpOnly Cookie 会话**(登录后服务端写入),`allow_credentials=True` 的 CORS 配置允许浏览器跨域携带 Cookie——这也引入了 CSRF 面。部署拓扑决定风险等级:
**同站部署(推荐)**: 前端与 API 同域(反代把 `/``/api` 都转发到同一后端,或同源端口)。
- 浏览器视为 **same-origin**,CORS 不触发;`SameSite=Lax` 会话 Cookie 天然阻断跨站请求携带。
- 风险最低。`CORS_ORIGINS` 可设为空或同域来源,仅作兜底。
**跨站部署**: 前端与 API 不同域(如前端 `app.example.com`、API `api.example.com`,或开发时 `localhost:3000``localhost:8000`)。
- 必须把 API 域名列入 `CORS_ORIGINS`,且 `allow_credentials=True` 才能携带 Cookie。
- 此时任何被允许域下的页面都能构造带 Cookie 的请求 → **CSRF 面**:
- 状态变更接口(采集/回测/改密等写操作)要求**管理员 Cookie + 同域**,攻击者无法从第三方站点读取 Cookie,但可构造跨域表单/请求——`SameSite=Lax` 会阻断跨站 POST 表单提交(顶级导航 GET 仍放行),这是当前主要防线。
- `GET /api/v1/admin/*` 只读接口受 `SameSite=Lax` 下顶级导航可能被利用,但攻击者无法读取响应(CORS 不匹配时浏览器拦截)。
- **加固建议**:
1. 反代层加 `Origin`/`Referer` 校验,仅放行 `CORS_ORIGINS` 列表中的来源(即便 FastAPI CORS 已通过,反代校验是多一层纵深)。
2. 写操作要求自定义请求头(如 `X-Requested-With: XMLHttpRequest`),第三方站点无法在无预检下添加自定义头,天然阻断简单跨站 POST。
3. 生产强制 HTTPS(`APP_ENV=production` 下 Cookie 自动 `Secure`),防中间人窃 Cookie。
## LLM 提供商配置示例
### OpenAI
+11 -7
View File
@@ -83,16 +83,16 @@ Profeto/
│ │ └── app.py # FastAPI 工厂
│ ├── db/
│ │ ├── base.py # SQLAlchemy async engine + session
│ │ ├── models.py # 6 张表 ORM
│ │ ├── models.py # 12 张表 ORM
│ │ ├── repositories.py # 仓储层(查询封装)
│ │ └── unit_of_work.py # 事务边界
│ ├── data/
│ │ ├── bzzoiro.py # bzzoiro 采集 + 入库
│ │ ├── understat.py # understat xG 回填
│ │ ├── injuries.py # 伤停采集
│ │ ├── bzzoiro.py # bzzoiro 采集 + 入库(唯一数据源)
│ │ ├── normalize.py # 数据清洗契约
│ │ ├── team_names.py # 队名归一化映射
│ │ ├── team_names_zh.py # 队名中文名映射
│ │ ├── sources.py # 数据源注册表
│ │ ├── key_ring.py # 数据源 Key 读取(DB 设置优先于 env)
│ │ └── config.py # 联赛映射常量
│ ├── llm/
│ │ ├── provider.py # LLM 提供商抽象(OpenAI-compatible)
@@ -110,13 +110,17 @@ Profeto/
│ │ ├── form_v1.md
│ │ ├── stats_v1.md
│ │ ├── home_away_v1.md
│ │ ├── injuries_v1.md
│ │ ├── standings_v1.md
│ │ ├── h2h_v1.md
│ │ └── aggregator_v1.md
│ └── core/
│ ├── config.py # pydantic-settings 配置
│ ├── http_client.py # 共享 httpx 客户端
── retry.py # 重试工具
── crypto.py # 对称加密(Fernet)与密码哈希
│ ├── log_buffer.py # 内存日志缓冲(admin「系统日志」页)
│ ├── runtime_config.py # 运行时配置(数据库优先,回落 .env)
│ ├── scheduler.py # 定时任务调度器(cron 触发采集)
│ └── security_check.py # 生产启动安全校验(缺配置拒绝启动)
├── frontend/ # React 单页前端
├── alembic/ # 数据库迁移
│ └── versions/
@@ -194,7 +198,7 @@ cp src/llm/prompts/agents/h2h_v1.md src/llm/prompts/agents/h2h_v2.md
### 3. 新增数据源
1.`src/data/` 写采集模块(参考 `understat.py`)
1.`src/data/` 写采集模块(参考 `bzzoiro.py`)
2.`normalize.py` 加清洗函数
3.`context_builder.py` 加切片函数
4.`api/routes/ingest.py` 加端点
+6
View File
@@ -1,3 +1,9 @@
> ⚠️ **历史文档(已过时)**:伤停(injuries)数据源与 api-football 集成已移除,
> 数据源收敛为 bzzoiro 唯一来源。本文仅作历史决策记录保留,
> 现状请见 [05-data.md](05-data.md) 与 [01-architecture.md](01-architecture.md)。
---
"""检查 injuries 数据源 api-football 的响应结构。"""
API_FOOTBALL_INJURY_RESPONSE_EXAMPLE = """
{
+51 -12
View File
@@ -11,9 +11,10 @@
* - 未登录访问管理 → AdminLayout 门禁 → 登录页(不静默失败)
*/
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { BrowserRouter, Routes, Route, Navigate, Link } from 'react-router-dom'
import { ErrorBoundary } from './components/ErrorBoundary'
import Matches from './pages/Matches'
import Standings from './pages/Standings'
import { adminRoutes } from './admin/routes'
/** 报眉日期行 */
@@ -26,6 +27,46 @@ function dateLine(): string {
})
}
function StandingsLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-paper-50">
<header className="masthead-rule">
<div className="mx-auto max-w-5xl px-5 sm:px-8">
<div className="border-b border-ink-900 py-5 text-center sm:py-6">
<h1 className="font-brush text-5xl text-ink-900 sm:text-6xl">
</h1>
</div>
<div className="flex items-center justify-between border-b border-ink-200 py-2 text-2xs text-ink-500">
<span>{dateLine()}</span>
<nav className="flex items-center gap-4" aria-label="页面导航">
<Link to="/" className="text-ink-500 hover:text-press transition-colors">
/
</Link>
<Link to="/admin/eval" className="text-ink-500 hover:text-press transition-colors">
</Link>
<Link to="/admin" className="flex items-center gap-1 text-ink-500 hover:text-press transition-colors">
<span aria-hidden="true"></span>
</Link>
</nav>
</div>
</div>
</header>
<main className="mx-auto max-w-5xl px-5 py-6 sm:px-8 sm:py-8">
{children}
</main>
<footer className="mx-auto max-w-5xl px-5 pb-10 sm:px-8">
<div className="border-t border-ink-200 pt-3 text-center text-2xs leading-relaxed text-ink-400">
bzzoiro ·
</div>
</footer>
</div>
)
}
function HomePage() {
return (
<div className="min-h-screen bg-paper-50">
@@ -33,25 +74,22 @@ function HomePage() {
<header className="masthead-rule">
<div className="mx-auto max-w-5xl px-5 sm:px-8">
<div className="border-b border-ink-900 py-5 text-center sm:py-6">
<h1 className="font-serif text-4xl font-bold tracking-widest text-ink-900">
<h1 className="font-brush text-5xl text-ink-900 sm:text-6xl">
<span className="ml-3 align-baseline font-serif text-base font-normal italic tracking-normal text-ink-500">
Profeto
</span>
</h1>
<p className="mt-2 text-2xs tracking-[0.4em] text-ink-500">
· ·
</p>
</div>
<div className="flex items-center justify-between border-b border-ink-200 py-2 text-2xs text-ink-500">
<span>{dateLine()}</span>
<nav className="flex items-center gap-4" aria-label="页面导航">
<a href="/admin/eval" className="text-ink-500 hover:text-press transition-colors">
<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">
</a>
<a href="/admin" className="flex items-center gap-1 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>
</a>
</Link>
</nav>
</div>
</div>
@@ -76,6 +114,7 @@ export default function App() {
<BrowserRouter>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/standings" element={<StandingsLayout><Standings /></StandingsLayout>} />
{adminRoutes.map(route => (
<Route key={route.path} path={route.path} element={route.element}>
{route.children.map(child => (
+158 -95
View File
@@ -9,34 +9,89 @@ import { useState, useEffect, useCallback } from 'react'
import { NavLink, Outlet, useLocation } from 'react-router-dom'
import { fetchAuthState, logout, UNAUTHORIZED_EVENT } from './api'
import { fetchHealth } from './dal'
import { COMMAND_PALETTE_PAGES, ROUTE_LABELS, NAV_SECTIONS } from './nav'
import Login from './Login'
import { useCommandPalette, CommandPalette } from './useCommandPalette'
const NAV_SECTIONS: { title: string; items: { to: string; label: string; icon: string; end?: boolean }[] }[] = [
{
title: '数据流水线',
items: [
{ to: '/admin/collection', label: '数据采集', icon: '◈' },
{ to: '/admin/predictions', label: '预测管理', icon: '◆' },
{ to: '/admin/backtest', label: '回测', icon: '◉' },
],
},
{
title: '评估与监控',
items: [
{ to: '/admin/eval', label: '评估', icon: '◈' },
{ to: '/admin/monitoring', label: '监控', icon: '◐' },
{ to: '/admin/logs', label: '日志', icon: '▤' },
],
},
{
title: '系统设置',
items: [
{ to: '/admin/data-sources', label: '数据源', icon: '◫' },
{ to: '/admin/llm-config', label: 'LLM 配置', icon: '◬' },
{ to: '/admin/config', label: '系统配置', icon: '◑' },
],
},
]
// 线条风格 SVG 图标组件
function Icon({ name }: { name: string }) {
const common = 'nav-icon'
switch (name) {
case 'collection':
return (
<svg className={common} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
<line x1="12" y1="22.08" x2="12" y2="12" />
</svg>
)
case 'chart':
return (
<svg className={common} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="20" x2="18" y2="10" />
<line x1="12" y1="20" x2="12" y2="4" />
<line x1="6" y1="20" x2="6" y2="14" />
</svg>
)
case 'target':
return (
<svg className={common} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<circle cx="12" cy="12" r="6" />
<circle cx="12" cy="12" r="2" />
</svg>
)
case 'repeat':
return (
<svg className={common} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
<polyline points="17 1 21 5 17 9" />
<path d="M3 11V9a4 4 0 0 1 4-4h14" />
<polyline points="7 23 3 19 7 15" />
<path d="M21 13v2a4 4 0 0 1-4 4H3" />
</svg>
)
case 'eval':
return (
<svg className={common} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
)
case 'monitor':
return (
<svg className={common} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
<line x1="8" y1="21" x2="16" y2="21" />
<line x1="12" y1="17" x2="12" y2="21" />
</svg>
)
case 'settings':
return (
<svg className={common} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68 1.65 1.65 0 0 0 10 3.17V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0-4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
)
case 'logs':
return (
<svg className={common} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="8" y1="13" x2="16" y2="13" />
<line x1="8" y1="17" x2="16" y2="17" />
</svg>
)
default:
return null
}
}
// D6: 导航三视图(侧栏/命令面板/面包屑)统一由 admin/nav.ts 的 NAV_ITEMS
// 单源派生 —— 此处的 NAV_PAGES/ROUTE_LABELS/NAV_SECTIONS 平行清单已删除,
// 新增页面/改标签只改 nav.ts 一处。
/** 报眉日期行,与前台同款式 */
function dateLine(): string {
@@ -53,6 +108,7 @@ export default function AdminLayout() {
const [healthOk, setHealthOk] = useState<boolean | null>(null)
const [authed, setAuthed] = useState<boolean | null>(null)
const location = useLocation()
const palette = useCommandPalette(COMMAND_PALETTE_PAGES)
// 登录门禁:挂载时探测会话,收到 401 事件(会话过期)自动切回登录页
useEffect(() => {
@@ -120,7 +176,55 @@ export default function AdminLayout() {
}
return (
<div className="flex h-screen overflow-hidden bg-paper-50 text-ink-800">
<div className="flex h-screen flex-col overflow-hidden bg-paper-50 text-ink-800">
{/* ── 通栏顶部报眉 ── */}
<header className="flex h-11 flex-shrink-0 items-center justify-between border-b border-ink-200 bg-paper-50 px-4 lg:px-6">
<div className="flex items-center gap-4">
{/* 移动端汉堡按钮 */}
<button
onClick={() => setSidebarOpen(true)}
className="-ml-1 p-2 text-ink-500 hover:text-ink-900 lg:hidden"
aria-label="打开菜单"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
{/* 面包屑 */}
<nav className="hidden items-center gap-1.5 text-2xs text-ink-400 sm:flex" aria-label="面包屑">
<a href="/admin" className="hover:text-ink-700"></a>
{location.pathname !== '/admin' && (
<>
<span aria-hidden="true">/</span>
<span className="text-ink-600">{ROUTE_LABELS[location.pathname] ?? '未知页面'}</span>
</>
)}
</nav>
<span className="hidden text-2xs text-ink-500 sm:inline">{dateLine()}</span>
</div>
<div className="flex items-center gap-4 text-2xs">
<span className="inline-flex items-center gap-1.5 text-ink-500">
<span
className={`inline-block h-1.5 w-1.5 ${
healthOk === null ? 'bg-ink-300' : healthOk ? 'bg-ink-900' : 'bg-press'
}`}
aria-hidden="true"
/>
{healthOk === null ? '检测中' : healthOk ? '系统正常' : '系统异常'}
</span>
<button
onClick={handleLogout}
className="text-ink-500 transition-colors hover:text-press"
title="退出登录"
>
</button>
</div>
</header>
{/* ── 主体:侧边栏 + 内容 ── */}
<div className="flex flex-1 overflow-hidden">
{/* ── 移动端遮罩层 ── */}
{sidebarOpen && (
<div
@@ -140,15 +244,9 @@ export default function AdminLayout() {
`}
aria-label="主导航"
>
{/* 报头 */}
<div className="flex items-center justify-between border-b border-ink-900 px-5 py-4">
<h1 className="font-serif text-lg font-bold tracking-widest text-ink-900">
<span className="ml-2 align-baseline font-serif text-xs font-normal italic tracking-normal text-ink-500">
Profeto
</span>
</h1>
<span className="text-2xs tracking-[0.25em] text-ink-400">ADMIN</span>
{/* 侧栏顶部 */}
<div className="flex items-center border-b border-ink-900 px-5 py-4">
<h1 className="font-brush text-xl text-ink-900"></h1>
</div>
{/* 导航:分组 + 小节标题 */}
@@ -158,14 +256,14 @@ export default function AdminLayout() {
to="/admin"
end
className={({ isActive }) =>
`flex min-h-[40px] items-center gap-2.5 px-3 text-sm transition-colors ${
`nav-icon-wrap flex min-h-[40px] items-center gap-2.5 border-l-4 px-3 text-sm transition-all duration-300 ${
isActive
? 'bg-press-wash/60 font-medium text-press'
: 'text-ink-500 hover:bg-paper-100 hover:text-ink-900'
? 'border-press bg-press-wash/60 font-medium text-press active'
: 'border-transparent text-ink-500 hover:bg-paper-100 hover:text-ink-900'
}`
}
>
<span className="text-base leading-none opacity-50" aria-hidden="true"></span>
<Icon name="chart" />
</NavLink>
@@ -180,22 +278,14 @@ export default function AdminLayout() {
<NavLink
to={item.to}
className={({ isActive }) =>
`flex min-h-[40px] items-center gap-2.5 px-3 text-sm transition-colors ${
`nav-icon-wrap flex min-h-[40px] items-center gap-2.5 border-l-4 px-3 text-sm transition-all duration-300 ${
isActive
? 'bg-press-wash/60 font-medium text-press'
: 'text-ink-500 hover:bg-paper-100 hover:text-ink-900'
? 'border-press bg-press-wash/60 font-medium text-press active'
: 'border-transparent text-ink-500 hover:bg-paper-100 hover:text-ink-900'
}`
}
>
<span
className={`inline-block h-1.5 w-1.5 flex-shrink-0 ${
location.pathname.startsWith(item.to) ? 'bg-press' : 'bg-transparent'
}`}
aria-hidden="true"
/>
<span className="text-base leading-none opacity-50" aria-hidden="true">
{item.icon}
</span>
<Icon name={item.icon} />
{item.label}
</NavLink>
</li>
@@ -206,7 +296,7 @@ export default function AdminLayout() {
</nav>
{/* 底部 */}
<div className="border-t border-ink-200 px-4 py-3">
<div className="border-t border-ink-200 px-4 py-3 space-y-2">
<a
href="/"
className="flex min-h-[36px] items-center gap-2 text-xs text-ink-500 transition-colors hover:text-press"
@@ -214,53 +304,26 @@ export default function AdminLayout() {
<span aria-hidden="true"></span>
</a>
<p className="text-2xs text-ink-300">v1.0</p>
</div>
</aside>
{/* ── 主内容区 ── */}
<div className="flex flex-1 flex-col overflow-hidden">
{/* 报眉:日期 + 系统状态 */}
<header className="flex h-11 flex-shrink-0 items-center justify-between border-b border-ink-200 px-4 lg:px-6">
<div className="flex items-center gap-4">
<button
onClick={() => setSidebarOpen(true)}
className="-ml-1 p-2 text-ink-500 hover:text-ink-900 lg:hidden"
aria-label="打开菜单"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<span className="hidden text-2xs text-ink-500 sm:inline">{dateLine()}</span>
</div>
<div className="flex items-center gap-4 text-2xs">
<span className="inline-flex items-center gap-1.5 text-ink-500">
<span
className={`inline-block h-1.5 w-1.5 ${
healthOk === null ? 'bg-ink-300' : healthOk ? 'bg-ink-900' : 'bg-press'
}`}
aria-hidden="true"
/>
{healthOk === null ? '检测中' : healthOk ? '系统正常' : '系统异常'}
</span>
<button
onClick={handleLogout}
className="text-ink-500 transition-colors hover:text-press"
title="退出登录"
>
</button>
</div>
</header>
{/* 页面内容 */}
<main className="flex-1 overflow-y-auto p-4 lg:p-8">
<div className="mx-auto max-w-6xl">
<Outlet />
</div>
</main>
<main className="flex-1 overflow-y-auto p-4 lg:p-8" key={location.pathname}>
<div className="mx-auto max-w-6xl page-content-enter">
<Outlet />
</div>
</main>
</div>
{/* 命令面板(⌘K) */}
<CommandPalette
open={palette.open}
query={palette.query}
setQuery={palette.setQuery}
items={palette.items}
onClose={() => palette.setOpen(false)}
/>
</div>
)
}
+20 -84
View File
@@ -1,99 +1,35 @@
/**
* Admin 后台管理系统 - 统一 API 客户端
* Admin 后台管理系统 - 统一 API 客户端(门面)
*
* 鉴权:通过 POST /api/v1/auth/login 用密码换取 HttpOnly Cookie 会话,
* 同源请求自动携带 Cookie,无需手动管理密钥。
* 收到 401 时广播 `profeto:unauthorized` 事件,由 AdminLayout 切回登录页。
*
* 实现已收敛到共享层 lib/http.ts(超时/错误解析/401 广播只此一份),
* 本文件仅保留 Admin 侧的门面签名与认证接口,供既有页面按原路径导入。
*/
import { http, ApiError, UNAUTHORIZED_EVENT } from '../lib/http'
/** Admin 侧兼容导出:错误类型与会话失效事件名的规范来源在 lib/http */
export { ApiError, UNAUTHORIZED_EVENT }
const API_BASE = '/api/v1'
const TIMEOUT_MS = 30_000
/** 会话失效事件名,AdminLayout 监听后弹出登录页 */
export const UNAUTHORIZED_EVENT = 'profeto:unauthorized'
export class ApiError extends Error {
constructor(
message: string,
public status: number,
public data?: unknown,
) {
super(message)
this.name = 'ApiError'
}
}
async function request<T>(
path: string,
options: RequestInit & { timeoutMs?: number; skipAuthHandling?: boolean } = {},
): Promise<T> {
// 修复: 正确拼接 API_BASE
const url = path.startsWith('http')
? path
: path.startsWith('/')
? path // 已经是绝对路径(如 /health)
: `${API_BASE}${path}`
const { timeoutMs = TIMEOUT_MS, skipAuthHandling, ...fetchOptions } = options
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
const res = await fetch(url, {
...fetchOptions,
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
...fetchOptions.headers,
},
})
if (!res.ok) {
let detail: unknown
try {
detail = await res.json()
} catch {
detail = await res.text()
}
let message =
detail && typeof detail === 'object' && 'detail' in detail
? String((detail as { detail: unknown }).detail)
: `HTTP ${res.status}: ${res.statusText}`
// 401 仅在没有显式跳过时广播未登录事件(改密接口的 401 表示当前密码错误,非会话过期)
if (res.status === 401 && !skipAuthHandling) {
message += '\n登录已过期,请重新登录。'
window.dispatchEvent(new CustomEvent(UNAUTHORIZED_EVENT))
}
throw new ApiError(message, res.status, detail)
}
// 修复: 正确判断 204 No Content
if (res.status === 204) {
return undefined as T
}
return res.json() as Promise<T>
} catch (err) {
if (err instanceof ApiError) throw err
if (err instanceof DOMException && err.name === 'AbortError') {
throw new ApiError('请求超时,请稍后重试', 0)
}
throw new ApiError(
err instanceof Error ? err.message : '网络错误,请检查连接',
0,
)
} finally {
clearTimeout(timer)
}
/** Admin 请求可覆盖项(与 lib/http RequestOptions 对齐的子集) */
type ApiOpts = {
timeoutMs?: number
/** 改密接口的 401 表示「当前密码错误」,非会话过期,置 true 跳过登出广播 */
skipAuthHandling?: boolean
}
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body?: unknown, opts?: { timeoutMs?: number; skipAuthHandling?: boolean }) =>
request<T>(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined, ...opts }),
put: <T>(path: string, body?: unknown, opts?: { timeoutMs?: number; skipAuthHandling?: boolean }) =>
request<T>(path, { method: 'PUT', body: body ? JSON.stringify(body) : undefined, ...opts }),
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
get: <T>(path: string) => http.get<T>(path),
post: <T>(path: string, body?: unknown, opts?: ApiOpts) =>
http.post<T>(path, body, opts),
put: <T>(path: string, body?: unknown, opts?: ApiOpts) =>
http.put<T>(path, body, opts),
delete: <T>(path: string) => http.delete<T>(path),
}
// ── 认证 ────────────────────────────────────────────────────────
+68 -17
View File
@@ -166,12 +166,12 @@ export function DataTable<T = any>({
// ── 进度条:同前台置信度细线 ────────────────────────────────────
export function ProgressBar({ value }: { value: number }) {
export function ProgressBar({ value, className = '' }: { value: number; className?: string }) {
const clamped = Math.max(0, Math.min(100, value))
return (
<div className="h-px w-full bg-ink-200" role="progressbar" aria-valuenow={clamped}>
<div className={`h-2 w-full overflow-hidden rounded-full bg-ink-200 ${className}`} role="progressbar" aria-valuenow={clamped}>
<div
className="h-px bg-press transition-[width] duration-500"
className="h-full rounded-full bg-press transition-[width] duration-500"
style={{ width: `${clamped}%` }}
/>
</div>
@@ -253,14 +253,19 @@ export function ResponsiveTable<T = any>({
export function SectionHeader({
title,
description,
action,
}: {
title: string
description?: string
action?: ReactNode
}) {
return (
<div className="mb-5">
<h2 className="section-head text-base">{title}</h2>
{description && <p className="mt-1.5 text-xs text-ink-500">{description}</p>}
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="section-head text-base">{title}</h2>
{description && <p className="mt-1.5 text-xs text-ink-500">{description}</p>}
</div>
{action}
</div>
)
}
@@ -272,11 +277,14 @@ export function Alert({
title,
message,
onClose,
action,
}: {
kind: 'error' | 'ok' | 'info' | 'warning'
title: string
message?: string
onClose?: () => void
/** 右侧操作按钮(如「去修复」) */
action?: ReactNode
}) {
const style =
kind === 'error'
@@ -304,17 +312,20 @@ export function Alert({
</p>
)}
</div>
{onClose && (
<button
onClick={onClose}
className="text-ink-400 transition-colors hover:text-ink-900"
aria-label="关闭"
>
<svg viewBox="0 0 20 20" className="h-4 w-4" fill="currentColor" aria-hidden="true">
<path d="M6.3 5.3a1 1 0 011.4 0L10 7.6l2.3-2.3a1 1 0 111.4 1.4L11.4 9l2.3 2.3a1 1 0 01-1.4 1.4L10 10.4l-2.3 2.3a1 1 0 01-1.4-1.4L8.6 9 6.3 6.7a1 1 0 010-1.4z" />
</svg>
</button>
)}
<div className="flex items-center gap-2">
{action}
{onClose && (
<button
onClick={onClose}
className="text-ink-400 transition-colors hover:text-ink-900"
aria-label="关闭"
>
<svg viewBox="0 0 20 20" className="h-4 w-4" fill="currentColor" aria-hidden="true">
<path d="M6.3 5.3a1 1 0 011.4 0L10 7.6l2.3-2.3a1 1 0 111.4 1.4L11.4 9l2.3 2.3a1 1 0 01-1.4 1.4L10 10.4l-2.3 2.3a1 1 0 01-1.4-1.4L8.6 9 6.3 6.7a1 1 0 010-1.4z" />
</svg>
</button>
)}
</div>
</div>
)
}
@@ -385,3 +396,43 @@ export function Spinner({ className = '' }: { className?: string }) {
export function SkeletonBlock({ className = '' }: { className?: string }) {
return <div className={`skeleton ${className}`} />
}
/** 空状态文本 */
export function EmptyText({ text }: { text: string }) {
return (
<div className="py-10 text-center text-sm text-ink-400">
{text}
</div>
)
}
/** Agent 权重条形图 */
export function AgentWeightsBar({ weights, okCount }: { weights: Record<string, number>; okCount: number }) {
const entries = Object.entries(weights).filter(([, w]) => w > 0)
if (entries.length === 0) return null
const total = entries.reduce((s, [, w]) => s + w, 0) || 1
const colors = ['bg-ink-900', 'bg-ink-700', 'bg-ink-500', 'bg-press', 'bg-ink-300']
return (
<div className="mt-2 border-t border-ink-200 pt-2">
<div className="mb-1 text-2xs text-ink-400"></div>
<div className="space-y-1">
{entries.map(([k, w], i) => (
<div key={k} className="flex items-center gap-2 text-2xs">
<div className="h-3.5 flex-1 overflow-hidden rounded-sm bg-ink-200/60">
<div
className={`h-full ${colors[i % colors.length]} transition-all duration-500`}
style={{ width: `${Math.max(3, Math.round((w / total) * 100))}%` }}
/>
</div>
<span className="w-12 text-right tabular-nums text-ink-500">
{Math.round((w / total) * 100)}%
</span>
<span className="w-24 truncate text-ink-400" title={k}>{k}</span>
</div>
))}
</div>
<div className="mt-1 text-2xs text-ink-400">{okCount}/{entries.length}</div>
</div>
)
}
+195 -41
View File
@@ -21,6 +21,7 @@ import type {
LLMAgentConfig,
LogEntry,
IngestSourceStatus,
IngestJob,
MatchDetailOut,
MatchContextOut,
AdminStats,
@@ -32,23 +33,21 @@ import type {
* 从多个端点聚合仪表盘数据。
* 后端暂无专用仪表盘端点,这里组合 health + 各列表端点。
*
* P2-8 修复: 使用 items.length 替代不存在的 total 字段,
* 并扩大 limit 以获得更有参考价值的数量。
* 注: 比赛真实总量请用 fetchAdminStats()(GET /admin/stats,
* 后端 COUNT(*) 精确计数)。此处曾用 matches items.length 近似,
* 已随仪表盘切换真实计数而移除,防止误用 100 上限的假总量。
*/
export async function fetchDashboard(): Promise<DashboardStats> {
// 并行获取各端点数据
// matches 返回 {items, next_cursor, has_more}, predictions 返回数组
const [leagues, matches, predictions, health] = await Promise.allSettled([
// predictions 返回数组
const [leagues, predictions, health] = await Promise.allSettled([
api.get<League[]>(`${API_BASE}/leagues`),
api.get<{ items: Match[]; has_more: boolean }>(`${API_BASE}/matches?limit=100`),
api.get<Prediction[]>(`${API_BASE}/predictions?limit=100`),
api.get<{ status: string }>('/health'),
])
return {
leagues: leagues.status === 'fulfilled' ? leagues.value : [],
// P2-8: matches 无 total 字段,用 items.length 近似(上限 100)
total_matches: matches.status === 'fulfilled' ? (matches.value as any)?.items?.length ?? 0 : 0,
// predictions 直接返回数组
total_predictions: predictions.status === 'fulfilled' ? (predictions.value as any)?.length ?? 0 : 0,
health: health.status === 'fulfilled' ? (health.value as any).status : 'unknown',
@@ -61,33 +60,16 @@ export async function fetchDashboard(): Promise<DashboardStats> {
// ── 数据采集 ────────────────────────────────────────────────────
export async function triggerCollection(req: CollectionRequest): Promise<any> {
const sourceMap: Record<string, { path: string; body: any }> = {
bzzoiro: {
path: `${API_BASE}/ingest/bzzoiro`,
body: {
leagues: req.leagues,
date_from: req.date_from,
date_to: req.date_to,
status: req.status || undefined, // 空 = 已完赛 + 未开赛都采集
},
},
understat: {
path: `${API_BASE}/ingest/understat`,
body: {
league: req.league,
season: req.season ? parseInt(req.season) : new Date().getFullYear(),
},
},
injuries: {
path: `${API_BASE}/ingest/injuries`,
body: {
date: req.date_from || new Date().toLocaleDateString('sv-SE'),
},
},
const body: Record<string, any> = {
leagues: req.leagues,
date_from: req.date_from,
date_to: req.date_to,
status: req.status || undefined,
task: req.task || 'events',
limit: req.limit || 100,
season: req.season || undefined,
}
const cfg = sourceMap[req.source]
if (!cfg) throw new Error(`未知数据源: ${req.source}`)
return api.post(cfg.path, cfg.body)
return api.post(`${API_BASE}/ingest/bzzoiro`, body)
}
// ── 预测管理 ────────────────────────────────────────────────────
@@ -183,6 +165,65 @@ export async function fetchHealth(): Promise<any> {
}
}
// ── 数据完整性 ──────────────────────────────────────────────────
export interface DataCompletenessResponse {
generated_at: string
totals: { finished_matches: number; stats_rows: number; stats_coverage_pct: number }
issues: string[]
leagues: Array<{
code: string
name: string
country?: string
matches: { total: number; finished: number; scheduled: number; with_source_id: number; earliest_match?: string; latest_match?: string }
stats: {
rows: number
fields: Record<string, { count: number; pct: number }>
}
standings: { rows: number; latest_retrieved?: string }
}>
}
export async function fetchDataCompleteness(): Promise<DataCompletenessResponse> {
return api.get<DataCompletenessResponse>(`${API_BASE}/admin/data-completeness`)
}
// ── 积分榜(主站 + 管理后台共用) ─────────────────────────────────
export interface StandingRow {
position: number
team: string
team_en: string
played: number
won: number
drawn: number
lost: number
goals_for: number
goals_against: number
goal_diff: number
points: number
xg_for: number | null
xg_against: number | null
form: string | null
zone: string | null
}
export interface StandingsLeague {
league_code: string
league_name: string
season: string
retrieved_at: string | null
rows: StandingRow[]
}
export async function fetchStandings(league?: string, season?: string): Promise<{ leagues: StandingsLeague[] }> {
const sp = new URLSearchParams()
if (league) sp.set('league', league)
if (season) sp.set('season', season)
const qs = sp.toString()
return api.get<{ leagues: StandingsLeague[] }>(`${API_BASE}/standings${qs ? `?${qs}` : ''}`)
}
// ── 数据源管理 ──────────────────────────────────────────────────
/**
@@ -251,14 +292,17 @@ export function clearSetting(key: string) {
* 测试 LLM 连接 — 调用预测端点验证
*/
export async function testLLMConnection(matchId?: number): Promise<any> {
return api.post(
`${API_BASE}/predict`,
{
match_id: matchId || 1,
mode: 'single',
},
{ timeoutMs: 300_000 },
)
// F4 修复: 优先使用不依赖比赛的 ping 端点
try {
return await api.post(`${API_BASE}/admin/llm/ping`, {})
} catch {
// 回退到旧方式(兼容)
return api.post(
`${API_BASE}/predict`,
{ match_id: matchId || 1, mode: 'single' },
{ timeoutMs: 300_000 },
)
}
}
/**
@@ -319,6 +363,13 @@ export function fetchIngestStatus(): Promise<{ sources: IngestSourceStatus[] }>
return api.get<{ sources: IngestSourceStatus[] }>(`${API_BASE}/admin/ingest/status`)
}
/**
* 采集任务状态轮询(单任务)
*/
export function fetchIngestJob(jobId: string): Promise<IngestJob> {
return api.get<IngestJob>(`${API_BASE}/admin/ingest/jobs/${jobId}`)
}
/**
* 比赛详情(含最近预测摘要)
*/
@@ -339,3 +390,106 @@ export function fetchMatchContext(id: number): Promise<MatchContextOut> {
export function fetchAdminStats(): Promise<AdminStats> {
return api.get<AdminStats>(`${API_BASE}/admin/stats`)
}
// ── API Key 轮换环 ──────────────────────────────────────────────
export interface KeyRingKeyStatus {
masked: string
blocked_remaining: number
}
export interface KeyRingStatusResponse {
base_url: string
total: number
has_multiple: boolean
cooldown_seconds: number
active_index: number
active_key: string | null
keys: KeyRingKeyStatus[]
}
export async function fetchKeyRingStatus(): Promise<KeyRingStatusResponse> {
return api.get<KeyRingStatusResponse>(`${API_BASE}/admin/keyring/status`)
}
export async function resetKeyRingCooldown(): Promise<{ ok: boolean; message: string; stats: KeyRingStatusResponse }> {
return api.post<{ ok: boolean; message: string; stats: KeyRingStatusResponse }>(`${API_BASE}/admin/keyring/cooldown/reset`)
}
// ── 定时任务 ────────────────────────────────────────────────────
export interface ScheduleItem {
id: string
task: string
cron: string
leagues?: string
enabled: boolean
last_run_at?: string | null
last_status?: string | null
}
export async function fetchSchedules(): Promise<ScheduleItem[]> {
return api.get<ScheduleItem[]>(`${API_BASE}/admin/schedules`)
}
export async function createSchedule(data: { id: string; task: string; cron: string; leagues?: string; enabled: boolean }): Promise<{ ok: boolean }> {
return api.post<{ ok: boolean }>(`${API_BASE}/admin/schedules`, data)
}
export async function updateSchedule(id: string, data: Partial<ScheduleItem>): Promise<{ ok: boolean }> {
return api.put<{ ok: boolean }>(`${API_BASE}/admin/schedules/${id}`, data)
}
export async function deleteSchedule(id: string): Promise<{ ok: boolean }> {
return api.delete<{ ok: boolean }>(`${API_BASE}/admin/schedules/${id}`)
}
export async function runScheduleNow(id: string): Promise<{ ok: boolean; message: string }> {
return api.post<{ ok: boolean; message: string }>(`${API_BASE}/admin/schedules/${id}/run`)
}
// ── 数据管线(质量检查 + 失败重试) ──────────────────────────────
export interface IngestFailureItem {
id: number
source: string
entity_type: string
source_record_id?: string
error_type: string
error_detail?: string
retry_count: number
status: string
next_retry_at?: string | null
created_at?: string
}
export interface DataQualityCheckItem {
id: number
check_name: string
entity_type: string
passed: boolean
severity: string
detail?: Record<string, unknown> | null
checked_at?: string
}
export interface DataQualityResponse {
failures: IngestFailureItem[]
checks: DataQualityCheckItem[]
}
export async function fetchDataQuality(): Promise<DataQualityResponse> {
return api.get<DataQualityResponse>(`${API_BASE}/admin/data-quality`)
}
export async function runDataQualityCheck(): Promise<{ ok: boolean; checks: Array<{ name: string; passed: boolean }> }> {
return api.post<{ ok: boolean; checks: Array<{ name: string; passed: boolean }> }>(`${API_BASE}/admin/data-quality/run`)
}
export async function fetchIngestFailures(): Promise<IngestFailureItem[]> {
return api.get<IngestFailureItem[]>(`${API_BASE}/admin/ingest-failures`)
}
export async function retryIngestFailure(id: number): Promise<{ ok: boolean; message: string }> {
return api.post<{ ok: boolean; message: string }>(`${API_BASE}/admin/ingest-failures/${id}/retry`)
}
+67
View File
@@ -0,0 +1,67 @@
/**
* Admin 导航单源(D6)。
*
* 背景: 侧栏(NAV_SECTIONS)、命令面板(NAV_PAGES)、面包屑(ROUTE_LABELS)
* 此前各维护一份平行清单,已出现漂移 —— data-pipeline 不在侧栏、
* 「系统」vs「系统设置」命名不一致、monitoring/eval 两处顺序相反。
*
* 现在只允许维护 NAV_ITEMS 一份,其余视图一律由此派生;
* 禁止再新建平行导航清单(修改入口/新增页面只改这里)。
*/
export interface NavItem {
to: string
label: string
/** 命令面板中的分组名(展示原样) */
group: string
/** 侧栏图标名(见 AdminLayout 的 Icon) */
icon: string
/** 仅命令面板/面包屑可达,不进侧栏(深链页) */
hideFromSidebar?: boolean
}
/** 唯一的导航配置源。顺序 = 侧栏渲染顺序(命令面板分组内顺序与之相同)。 */
export const NAV_ITEMS: NavItem[] = [
{ to: '/admin', label: '仪表盘', group: '概览', icon: 'chart' },
{ to: '/admin/collection', label: '数据采集', group: '数据流水线', icon: 'collection' },
{ to: '/admin/data-completeness', label: '数据完整性', group: '数据流水线', icon: 'chart' },
{ to: '/admin/data-pipeline', label: '数据管线', group: '数据流水线', icon: 'chart', hideFromSidebar: true },
{ to: '/admin/predictions', label: '预测历史', group: '数据流水线', icon: 'logs' },
{ to: '/admin/backtest', label: '回测', group: '数据流水线', icon: 'repeat' },
{ to: '/admin/eval', label: '评估', group: '评估与监控', icon: 'eval' },
{ to: '/admin/monitoring', label: '监控', group: '评估与监控', icon: 'monitor' },
{ to: '/admin/settings', label: '设置', group: '系统', icon: 'settings' },
{ to: '/admin/logs', label: '日志', group: '系统', icon: 'logs' },
]
/** 命令面板条目(原 NAV_PAGES 的唯一来源) */
export const COMMAND_PALETTE_PAGES = NAV_ITEMS.map(({ to, label, group }) => ({ to, label, group }))
/** 路由 → 面包屑标签(原 ROUTE_LABELS 的唯一来源) */
export const ROUTE_LABELS: Record<string, string> = Object.fromEntries(
NAV_ITEMS.map(i => [i.to, i.label]),
)
/** 侧栏分组标题的历史显示名(仅侧栏使用;与面板分组名不同时在此映射) */
const SIDEBAR_GROUP_TITLES: Record<string, string> = {
: '系统设置',
}
/**
* 侧栏分组(原 NAV_SECTIONS 的唯一来源)。
* 仪表盘(group=概览)在 AdminLayout 中独立渲染于顶部,不进分组循环。
*/
export const NAV_SECTIONS = (() => {
const sidebarItems = NAV_ITEMS.filter(i => !i.hideFromSidebar && i.group !== '概览')
const titles: string[] = []
for (const i of sidebarItems) {
const title = SIDEBAR_GROUP_TITLES[i.group] ?? i.group
if (!titles.includes(title)) titles.push(title)
}
return titles.map(title => ({
title,
items: sidebarItems
.filter(i => (SIDEBAR_GROUP_TITLES[i.group] ?? i.group) === title)
.map(({ to, label, icon }) => ({ to, label, icon })),
}))
})()
+265 -121
View File
@@ -1,56 +1,63 @@
/**
* Admin 后台 - 数据采集页面(报刊风)
* Admin 后台 - 数据采集页面(bzzoiro 单一数据源)
*
* 三个采集任务:
* events — 比赛日程与比分
* standings — 联赛积分榜
* stats — 已完赛比赛详细统计回填
* all — 依次执行以上三项
*
* 响应式布局: 移动端单列,桌面端双列
*/
import { useEffect, useState, useCallback } from 'react'
import { triggerCollection, fetchLeagues } from '../dal'
import type { CollectionRequest, League } from '../types'
import { useEffect, useState, useCallback, useRef } from 'react'
import { triggerCollection, fetchLeagues, fetchIngestJob } from '../dal'
import type { IngestJob, League } from '../types'
import type { CollectionRequest } from '../types'
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
const SOURCES = [
{ value: 'bzzoiro', label: 'Bzzoiro', desc: '历史赛程与比分' },
{ value: 'understat', label: 'Understat', desc: 'xG 进阶数据' },
{ value: 'injuries', label: 'Injuries', desc: '球员伤停' },
const TASKS = [
{ value: 'events', label: '比赛数据', desc: '赛程 / 比分 / 未开赛安排', icon: '⚽' },
{ value: 'standings', label: '积分榜', desc: '联赛排名 / 积分 / xG差 / 近期走势', icon: '🏆' },
{ value: 'stats', label: '统计回填', desc: '已完赛比赛的 xG / 射门 / 控球等详细统计', icon: '📊' },
{ value: 'all', label: '全量采集', desc: '依次采集比赛 + 积分榜 + 统计回填', icon: '⏵⏵' },
] as const
/** 把采集接口返回摘要成一两行可读文字 */
function summarizeResult(res: any, source: string): { title: string; detail: string } {
if (res && typeof res === 'object') {
if (source === 'bzzoiro' && ('total_inserted' in res || 'total_updated' in res)) {
return {
title: `采集完成:新增 ${res.total_inserted ?? 0} 条,更新 ${res.total_updated ?? 0}`,
detail: Array.isArray(res.errors) && res.errors.length > 0 ? res.errors.join('\n') : '',
}
}
if ('count' in res || 'updated' in res) {
const parts = [
`新增 ${res.count ?? 0}`,
`更新 ${res.updated ?? 0}`,
`跳过 ${res.skipped ?? 0}`,
`未匹配 ${res.unmatched ?? 0}`,
]
return {
title: `采集完成:${parts.join(' / ')}`,
detail: Array.isArray(res.errors) && res.errors.length > 0 ? res.errors.join('\n') : '',
}
}
}
return { title: '采集完成', detail: JSON.stringify(res)?.slice(0, 300) ?? '' }
}
type TaskUIStatus = 'idle' | 'running' | 'done' | 'error'
const TERMINAL_STATUSES: ReadonlySet<string> = new Set(['success', 'failed'])
export default function CollectionPage() {
const [leagues, setLeagues] = useState<League[]>([])
const [source, setSource] = useState<string>('bzzoiro')
const [task, setTask] = useState<string>('events')
const [leagueCode, setLeagueCode] = useState('')
// 从 URL 查询参数预填充(支持从「数据完整性」页跳转)
useEffect(() => {
const sp = new URLSearchParams(window.location.search)
const taskParam = sp.get('task')
const leagueParam = sp.get('league')
if (taskParam && ['events', 'standings', 'stats', 'all'].includes(taskParam)) {
setTask(taskParam)
}
if (leagueParam) {
setLeagueCode(leagueParam)
}
}, [])
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [season, setSeason] = useState('')
const [ingestStatus, setIngestStatus] = useState('') // 空 = 已完赛+未开赛
const [ingestStatus, setIngestStatus] = useState('')
const [limit, setLimit] = useState(100)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [result, setResult] = useState<{ title: string; detail: string } | null>(null)
// 任务进度反馈:跟踪真实 ingest_job 状态
const [taskStatus, setTaskStatus] = useState<TaskUIStatus>('idle')
const [jobId, setJobId] = useState<string | null>(null)
const [jobInfo, setJobInfo] = useState<IngestJob | null>(null)
const [taskStartedAt, setTaskStartedAt] = useState<number | null>(null)
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const loadLeagues = useCallback(async () => {
const lg = await fetchLeagues()
@@ -59,39 +66,106 @@ export default function CollectionPage() {
useEffect(() => { loadLeagues() }, [loadLeagues])
// 轮询采集 job 直到终态(success/failed)
const stopPolling = useCallback(() => {
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null }
}, [])
useEffect(() => () => stopPolling(), [stopPolling])
const startJobPolling = useCallback((id: string) => {
stopPolling()
const tick = async () => {
try {
const job = await fetchIngestJob(id)
setJobInfo(job)
if (TERMINAL_STATUSES.has(job.status)) {
setTaskStatus(job.status === 'success' ? 'done' : 'error')
stopPolling()
}
} catch { /* 单次轮询失败不影响后续 */ }
}
tick()
pollRef.current = setInterval(tick, 3_000)
}, [stopPolling])
const isEventsTask = task === 'events' || task === 'all'
// 友好汇总 job.result
const jobSummary = (j: IngestJob | null): { title: string; detail: string } | null => {
if (!j) return null
if (j.status === 'failed') {
return { title: '采集失败', detail: j.error || '采集任务异常终止,请到「系统日志」查看详细堆栈。' }
}
if (j.status !== 'success') return null
const r = j.result as Record<string, unknown> | null
if (!r) return { title: '采集完成', detail: '任务成功(无汇总数据)。' }
const ev = r.events as Record<string, unknown> | undefined
const evTotal = ev ? (ev.total_inserted as number ?? 0) + (ev.total_updated as number ?? 0) : 0
const st = r.standings as Record<string, unknown> | undefined
const stTotal = st ? (st.total_upserted as number ?? 0) : 0
const stats = r.stats as Record<string, unknown> | undefined
const statsTotal = stats ? (stats.created as number ?? 0) + (stats.updated as number ?? 0) : 0
const evErr = (ev?.errors as string[] | undefined)?.length ?? 0
const stErr = (st?.errors as string[] | undefined)?.length ?? 0
const statsErr = (stats?.errors as string[] | undefined)?.length ?? 0
const totalErr = evErr + stErr + statsErr
const parts: string[] = []
if (ev) parts.push(`比赛 +${evTotal}`)
if (st) parts.push(`积分榜 +${stTotal}`)
if (stats) parts.push(`统计 +${statsTotal}`)
const detail = parts.length
? `共更新: ${parts.join(' / ')}${totalErr ? `,错误 ${totalErr} 条(见日志)` : ''}`
: '任务成功'
return { title: '采集完成', detail }
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
setResult(null)
setJobInfo(null)
setJobId(null)
setLoading(true)
setTaskStatus('running')
setTaskStartedAt(Date.now())
try {
const body: CollectionRequest = {
source: source as CollectionRequest['source'],
source: 'bzzoiro',
leagues: leagueCode ? [leagueCode] : undefined,
league: leagueCode || undefined,
task: task as CollectionRequest['task'],
limit,
season: season || undefined,
status: ingestStatus || undefined,
date_from: dateFrom || undefined,
date_to: dateTo || undefined,
date_from: isEventsTask ? dateFrom || undefined : undefined,
date_to: isEventsTask ? dateTo || undefined : undefined,
}
const res = await triggerCollection(body)
const id: string | undefined = res?.job_id
if (id) {
setJobId(id)
startJobPolling(id)
} else {
// 后端未返回 job_id(旧版兼容):退化为原逻辑
setTimeout(() => { setTaskStatus('done'); }, 30_000)
}
await triggerCollection(body)
setResult({
title: '采集任务已启动',
detail: '正在后台执行(上游限速时可能需要几分钟)。完成结果与错误请到「系统日志」页查看(支持自动刷新)。',
})
} catch (err: unknown) {
setTaskStatus('error')
setError(err instanceof Error ? err.message : '采集触发失败')
stopPolling()
} finally {
setLoading(false)
}
}
const elapsed = taskStartedAt ? Math.round((Date.now() - taskStartedAt) / 1000) : 0
const summary = jobInfo ? jobSummary(jobInfo) : null
return (
<div className="space-y-6">
<SectionHeader
title="数据采集"
description="触发数据源采集,支持联赛筛选和日期范围。采集为同步执行,大范围日期耗时较长。"
description="bzzoiro 单一数据源:比赛数据、积分榜、比赛统计三条管线。采集为后台异步执行。"
/>
<div className="grid gap-6 lg:grid-cols-2">
@@ -100,20 +174,26 @@ export default function CollectionPage() {
<CardHeader title="新建采集任务" />
<CardBody>
<form onSubmit={handleSubmit} className="space-y-4">
{/* 数据源选择 */}
{/* 任务类型 */}
<div>
<label className="mb-1.5 block text-xs text-ink-500"></label>
<select
value={source}
onChange={e => setSource(e.target.value)}
className="field w-full"
>
{SOURCES.map(s => (
<option key={s.value} value={s.value}>
{s.label} {s.desc}
</option>
<label className="mb-1.5 block text-xs text-ink-500"></label>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{TASKS.map(t => (
<button
key={t.value}
type="button"
onClick={() => setTask(t.value)}
className={`rounded-lg border px-3 py-2 text-left text-xs transition-colors ${
task === t.value
? 'border-brand-500 bg-brand-50 text-brand-700'
: 'border-ink-200 text-ink-600 hover:border-ink-300'
}`}
>
<span className="mr-1">{t.icon}</span>
<span className="font-medium">{t.label}</span>
</button>
))}
</select>
</div>
</div>
{/* 联赛选择 */}
@@ -131,68 +211,83 @@ export default function CollectionPage() {
</select>
</div>
{/* Bzzoiro 专用: 比赛状态 */}
{source === 'bzzoiro' && (
<div>
<label className="mb-1.5 block text-xs text-ink-500"></label>
<select
value={ingestStatus}
onChange={e => setIngestStatus(e.target.value)}
className="field w-full"
>
<option value="">( + )</option>
<option value="finished"></option>
<option value="scheduled"></option>
</select>
</div>
{/* events/all 任务专用: 比赛状态 + 日期 */}
{isEventsTask && (
<>
<div>
<label className="mb-1.5 block text-xs text-ink-500"></label>
<select
value={ingestStatus}
onChange={e => setIngestStatus(e.target.value)}
className="field w-full"
>
<option value="">( + )</option>
<option value="finished"></option>
<option value="scheduled"></option>
</select>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="mb-1.5 block text-xs text-ink-500"></label>
<input
type="date"
value={dateFrom}
onChange={e => setDateFrom(e.target.value)}
className="field w-full"
/>
</div>
<div>
<label className="mb-1.5 block text-xs text-ink-500"></label>
<input
type="date"
value={dateTo}
onChange={e => setDateTo(e.target.value)}
className="field w-full"
/>
</div>
</div>
</>
)}
{/* Understat 专用: 赛季 */}
{source === 'understat' && (
{/* standings 任务专用: 赛季 */}
{(task === 'standings') && (
<div>
<label className="mb-1.5 block text-xs text-ink-500">()</label>
<label className="mb-1.5 block text-xs text-ink-500">()</label>
<input
type="number"
type="text"
value={season}
onChange={e => setSeason(e.target.value)}
placeholder="2025"
placeholder="2026-2027"
className="field w-full"
/>
</div>
)}
{/* 日期范围 */}
{source !== 'injuries' && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="mb-1.5 block text-xs text-ink-500"></label>
<input
type="date"
value={dateFrom}
onChange={e => setDateFrom(e.target.value)}
className="field w-full"
/>
</div>
<div>
<label className="mb-1.5 block text-xs text-ink-500"></label>
<input
type="date"
value={dateTo}
onChange={e => setDateTo(e.target.value)}
className="field w-full"
/>
</div>
{/* stats 任务专用: 回填数量 */}
{(task === 'stats') && (
<div>
<label className="mb-1.5 block text-xs text-ink-500">(1-500)</label>
<input
type="number"
min={1}
max={500}
value={limit}
onChange={e => setLimit(parseInt(e.target.value) || 100)}
className="field w-full"
/>
<p className="mt-1 text-2xs text-ink-400">
source_event_id (), 1.2 /
</p>
</div>
)}
{/* 消息提示 */}
{error && <Alert kind="error" title="采集失败" message={error} onClose={() => setError(null)} />}
{result && (
{summary && (
<Alert
kind="ok"
title={result.title}
message={result.detail || undefined}
onClose={() => setResult(null)}
kind={jobInfo?.status === 'success' ? 'ok' : 'error'}
title={summary.title}
message={summary.detail}
/>
)}
@@ -204,27 +299,76 @@ export default function CollectionPage() {
</CardBody>
</Card>
{/* 数据源说明 */}
<Card>
<CardHeader title="数据源说明" />
<CardBody>
<div className="space-y-3">
{SOURCES.map(s => (
<div key={s.value} className="border-b border-ink-200 px-1 py-3 last:border-b-0">
<div className="flex items-center gap-3">
<Badge status="info">{s.label}</Badge>
<p className="text-xs text-ink-600">{s.desc}</p>
{/* 任务状态 + 数据源说明 */}
<div className="space-y-6">
{/* 任务进度 */}
<Card>
<CardHeader title="任务状态" />
<CardBody>
{taskStatus === 'idle' && (
<p className="text-xs text-ink-400"></p>
)}
{taskStatus === 'running' && (
<div className="space-y-3">
<div className="flex items-center gap-2 text-xs text-ink-700">
<Spinner />
<span>{jobId ? `(job ${jobId.slice(0, 8)}…)` : ''}, {elapsed}s</span>
</div>
<p className="text-2xs text-ink-400">
, 3
</p>
</div>
))}
</div>
)}
{taskStatus === 'done' && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs text-emerald-700">
<span className="inline-block h-2 w-2 rounded-full bg-emerald-500" />
<span>{jobId ? `(job ${jobId.slice(0, 8)}…)` : ''}</span>
</div>
{summary && <p className="text-2xs text-ink-500">{summary.detail}</p>}
</div>
)}
{taskStatus === 'error' && (
<div className="space-y-1">
<p className="text-xs text-press">{jobId ? `(job ${jobId.slice(0, 8)}…)` : ''}</p>
{jobInfo?.error && (
<p className="text-2xs text-ink-500">{jobInfo.error.slice(0, 200)}</p>
)}
</div>
)}
{jobInfo?.created_at && (
<p className="mt-2 text-2xs text-ink-400">
{new Date(jobInfo.created_at).toLocaleString('zh-CN', { hour12: false })}
{jobInfo.finished_at && ` · 完成于 ${new Date(jobInfo.finished_at).toLocaleString('zh-CN', { hour12: false })}`}
</p>
)}
</CardBody>
</Card>
<p className="mt-4 border-l-2 border-ink-300 pl-3 text-2xs leading-relaxed text-ink-500">
401 ,
</p>
</CardBody>
</Card>
{/* 数据源说明 */}
<Card>
<CardHeader title="采集任务说明" />
<CardBody>
<div className="space-y-3">
{TASKS.map(t => (
<div key={t.value} className="border-b border-ink-200 px-1 py-3 last:border-b-0">
<div className="flex items-center gap-3">
<span>{t.icon}</span>
<Badge status="info">{t.label}</Badge>
<p className="text-xs text-ink-600">{t.desc}</p>
</div>
</div>
))}
</div>
<p className="mt-4 border-l-2 border-ink-300 pl-3 text-2xs leading-relaxed text-ink-500">
(401 )
线 bzzoiro (Understat / injuries )
线 source_event_id,
</p>
</CardBody>
</Card>
</div>
</div>
</div>
)
+33 -16
View File
@@ -14,7 +14,7 @@ import { Card, CardBody, CardHeader, Alert, SkeletonBlock } from '../components'
/** 工作流步骤卡片 */
const STEPS = [
{ to: '/admin/collection', step: '1', title: '采集数据', desc: 'bzzoiro / understat 获取赛程与 xG', icon: '◈' },
{ to: '/admin/collection', step: '1', title: '采集数据', desc: 'bzzoiro: 赛程 / 积分榜 / 比赛统计(xG、射门、控球等)', icon: '◈' },
{ to: '/admin/predictions', step: '2', title: '运行预测', desc: '调 LLM 多专家生成比分预测', icon: '◆' },
{ to: '/admin/eval', step: '3', title: '评估准确率', desc: '结算后查看 1X2 命中率与校准度', icon: '◈' },
]
@@ -42,8 +42,6 @@ export default function Dashboard() {
const sourceByName = Object.fromEntries(ingest.map(s => [s.name, s]))
const bzzoiro = sourceByName['bzzoiro']
const understat = sourceByName['understat']
const injuries = sourceByName['injuries']
return (
<div className="space-y-6">
@@ -83,8 +81,6 @@ export default function Dashboard() {
<div>
{[
{ name: 'bzzoiro', label: 'Bzzoiro', st: bzzoiro },
{ name: 'understat', label: 'Understat (xG)', st: understat },
{ name: 'injuries', label: 'Injuries (伤停)', st: injuries },
].map(({ name, label, st }) => {
const hasData = st && st.recent_count > 0
const keyOk = st?.key_configured !== false
@@ -120,18 +116,39 @@ export default function Dashboard() {
<CardHeader title="近期预测活动" description="预测 API 的调用量统计" />
<CardBody>
{stats ? (
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<div className="font-serif text-3xl font-bold tabular-nums text-ink-900">{stats.predictions.last_24h}</div>
<div className="mt-1 text-2xs text-ink-400"> 24 </div>
<div className="space-y-4">
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<div className="font-serif text-3xl font-bold tabular-nums text-ink-900">{stats.predictions.last_24h}</div>
<div className="mt-1 text-2xs text-ink-400"> 24 </div>
</div>
<div>
<div className="font-serif text-3xl font-bold tabular-nums text-ink-900">{stats.predictions.last_7d}</div>
<div className="mt-1 text-2xs text-ink-400"> 7 </div>
</div>
<div>
<div className="font-serif text-3xl font-bold tabular-nums text-ink-900">{stats.predictions.total}</div>
<div className="mt-1 text-2xs text-ink-400"></div>
</div>
</div>
<div>
<div className="font-serif text-3xl font-bold tabular-nums text-ink-900">{stats.predictions.last_7d}</div>
<div className="mt-1 text-2xs text-ink-400"> 7 </div>
</div>
<div>
<div className="font-serif text-3xl font-bold tabular-nums text-ink-900">{stats.predictions.total}</div>
<div className="mt-1 text-2xs text-ink-400"></div>
{/* F3 修复: 真实比赛计数(非 limit=100 近似) */}
<div className="grid grid-cols-4 gap-3 border-t border-ink-200 pt-3 text-center">
<div>
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">{stats.matches?.total ?? 0}</div>
<div className="mt-0.5 text-2xs text-ink-400"></div>
</div>
<div>
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">{stats.matches?.finished ?? 0}</div>
<div className="mt-0.5 text-2xs text-ink-400"></div>
</div>
<div>
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">{stats.stats?.total ?? 0}</div>
<div className="mt-0.5 text-2xs text-ink-400"></div>
</div>
<div>
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">{stats.standings?.total ?? 0}</div>
<div className="mt-0.5 text-2xs text-ink-400"></div>
</div>
</div>
</div>
) : (
@@ -0,0 +1,241 @@
/**
* Admin 后台 - 数据完整性分析页
*
* 回答三个问题:
* 1. 数据是否齐全(各联赛比赛/统计/积分榜量级)
* 2. 字段是否齐全(每张统计表各字段非空率)
* 3. 覆盖是否新鲜(最近一场/最近一次采集)
*/
import { useEffect, useState, useCallback, useRef } from 'react'
import { fetchDataCompleteness } from '../dal'
import type { DataCompletenessResponse } from '../dal'
import {
Card, CardBody, CardHeader, SectionHeader, Alert,
ProgressBar, Spinner, EmptyState,
} from '../components'
const FIELD_LABELS: Record<string, string> = {
xg: 'xG 预期进球',
shots: '射门',
possession: '控球率',
corners: '角球',
fouls: '犯规',
big_chances: '绝佳机会',
cards: '红黄牌',
}
function pctColor(pct: number): string {
if (pct >= 80) return 'bg-emerald-500'
if (pct >= 50) return 'bg-amber-500'
return 'bg-rose-500'
}
/** 根据问题描述生成可操作的修复链接 */
function getIssueAction(issue: string): { href: string; label: string; code: string } | null {
// 提取联赛代码(大写字母+数字,如 E0, SP1)
const codeMatch = issue.match(/\b([A-Z]{1,2}\d?)\b/)
const code = codeMatch ? codeMatch[1] : ''
if (!code) return null
if (issue.includes('无已完赛比赛')) {
return { href: `/admin/collection?task=events&league=${code}`, label: '去采集', code }
}
if (issue.includes('无统计回填') || issue.includes('统计覆盖率')) {
return { href: `/admin/collection?task=stats&league=${code}`, label: '去回填', code }
}
if (issue.includes('无积分榜')) {
return { href: `/admin/collection?task=standings&league=${code}`, label: '去采集', code }
}
return null
}
export default function DataCompletenessPage() {
const [data, setData] = useState<DataCompletenessResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [highlightedLeague, setHighlightedLeague] = useState<string | null>(null)
const leagueRefs = useRef<Record<string, HTMLDivElement | null>>({})
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const d = await fetchDataCompleteness()
setData(d)
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
// 点击问题项 → 滚动到对应联赛卡片并高亮
const scrollToLeague = useCallback((code: string) => {
setHighlightedLeague(code)
// 使用 requestAnimationFrame 确保 DOM 已更新
requestAnimationFrame(() => {
const el = leagueRefs.current[code]
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
})
// 5秒后移除高亮
setTimeout(() => setHighlightedLeague(null), 5000)
}, [])
return (
<div className="space-y-6">
<SectionHeader
title="数据完整性"
description="按联赛统计 bzzoiro 数据采集覆盖度。每 5 秒自动刷新,或点击右上角按钮手动刷新。"
action={
<button onClick={load} disabled={loading} className="btn-sm btn-outline">
{loading ? <><Spinner /> </> : '刷新'}
</button>
}
/>
{error && <Alert kind="error" title="加载失败" message={error} onClose={() => setError(null)} />}
{loading && !data && (
<div className="flex justify-center py-12"><Spinner /></div>
)}
{data && (
<>
{/* 全局概览 */}
<div className="grid gap-4 sm:grid-cols-3">
<Card>
<CardBody className="text-center">
<p className="text-2xl font-bold text-ink-900">{data.totals.finished_matches}</p>
<p className="text-xs text-ink-500">()</p>
</CardBody>
</Card>
<Card>
<CardBody className="text-center">
<p className="text-2xl font-bold text-ink-900">{data.totals.stats_rows}</p>
<p className="text-xs text-ink-500"></p>
</CardBody>
</Card>
<Card>
<CardBody className="text-center">
<p className="text-2xl font-bold text-ink-900">
{data.totals.stats_coverage_pct}%
</p>
<p className="text-xs text-ink-500">( / )</p>
</CardBody>
</Card>
</div>
{/* 健康问题(可操作) */}
<Card>
<CardHeader title="健康摘要" />
<CardBody>
<div className="space-y-2">
{data.issues.map((issue, i) => {
const action = getIssueAction(issue)
return (
<Alert
key={i}
kind={issue.includes('良好') ? 'ok' : issue.includes('建议') || issue.includes('仅') ? 'warning' : 'error'}
title={issue.includes('良好') ? '数据良好' : '需要关注'}
message={issue}
action={action ? (
<div className="flex items-center gap-2">
<button
onClick={() => scrollToLeague(action.code)}
className="btn btn-sm whitespace-nowrap"
>
</button>
<a href={action.href} className="btn btn-sm whitespace-nowrap">
{action.label}
</a>
</div>
) : undefined}
/>
)
})}
</div>
</CardBody>
</Card>
{/* 各联赛详情 */}
<div className="space-y-4">
{data.leagues.map(league => {
const finished = league.matches.finished
const statsPct = finished > 0 ? Math.round((league.stats.rows / finished) * 100) : 0
const isHighlighted = highlightedLeague === league.code
return (
<div
key={league.code}
ref={el => { leagueRefs.current[league.code] = el }}
className={`transition-all duration-500 ${isHighlighted ? 'ring-2 ring-press bg-press-wash/40 scale-[1.01]' : ''}`}
>
<Card>
<CardHeader
title={league.name}
description={[
league.country,
`已完赛 ${finished} 场 / 未开赛 ${league.matches.scheduled}`,
league.matches.latest_match ? `最近: ${league.matches.latest_match.slice(0, 10)}` : '',
league.standings.rows > 0 ? `积分榜 ${league.standings.rows}` : '',
].filter(Boolean).join(' · ')}
/>
<CardBody className="space-y-4">
{/* 统计覆盖率进度条 */}
<div>
<div className="mb-1 flex items-center justify-between text-xs">
<span className="text-ink-600"></span>
<span className="font-medium text-ink-900">
{league.stats.rows} / {finished} ({statsPct}%)
</span>
</div>
<ProgressBar value={statsPct} />
</div>
{/* 字段覆盖率矩阵 */}
{league.stats.rows > 0 ? (
<div>
<p className="mb-2 text-xs font-medium text-ink-600">( / )</p>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
{Object.entries(league.stats.fields).map(([key, info]) => (
<div key={key} className="rounded border border-ink-100 px-3 py-2">
<div className="mb-1 flex items-center justify-between">
<span className="text-xs text-ink-600">{FIELD_LABELS[key] ?? key}</span>
<span className="text-xs font-medium text-ink-900">{info.pct}%</span>
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-ink-100">
<div
className={`h-full rounded-full ${pctColor(info.pct)}`}
style={{ width: `${info.pct}%` }}
/>
</div>
<p className="mt-0.5 text-2xs text-ink-400">{info.count} / {league.stats.rows} </p>
</div>
))}
</div>
</div>
) : finished > 0 ? (
<Alert kind="warning" title="缺少统计数据" message="该联赛有已完赛比赛但无统计行,请运行「统计回填」采集。" />
) : (
<Alert kind="warning" title="缺少比赛数据" message="该联赛暂无已完赛比赛,请运行「比赛数据」采集。" />
)}
</CardBody>
</Card>
</div>
)
})}
</div>
<p className="text-center text-2xs text-ink-400">
: {new Date(data.generated_at).toLocaleString('zh-CN', { hour12: false })}
</p>
</>
)}
</div>
)
}
+199
View File
@@ -0,0 +1,199 @@
/**
* Admin 后台 - 数据管线管理页(报刊风)
*
* 功能:
* - 采集失败记录列表(可重试)
* - 数据质量检查结果
* - 手动触发质量检查
*/
import { useEffect, useState, useCallback } from 'react'
import {
fetchDataQuality,
runDataQualityCheck,
fetchIngestFailures,
retryIngestFailure,
} from '../dal'
import type { IngestFailureItem, DataQualityCheckItem } from '../dal'
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
export default function DataPipelinePage() {
const [quality, setQuality] = useState<{ failures: IngestFailureItem[]; checks: DataQualityCheckItem[] } | null>(null)
const [failures, setFailures] = useState<IngestFailureItem[]>([])
const [loading, setLoading] = useState(true)
const [running, setRunning] = useState(false)
const [error, setError] = useState<string | null>(null)
const [notice, setNotice] = useState<{ ok: boolean; text: string } | null>(null)
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [q, f] = await Promise.all([fetchDataQuality(), fetchIngestFailures()])
setQuality(q)
setFailures(f)
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
const handleRunCheck = async () => {
setRunning(true)
setNotice(null)
try {
const res = await runDataQualityCheck()
const failed = res.checks.filter(c => !c.passed)
setNotice({
ok: failed.length === 0,
text: failed.length === 0
? '数据质量检查通过'
: `检查完成: ${failed.length} 项未通过`,
})
await load()
} catch {
setNotice({ ok: false, text: '质量检查执行失败' })
} finally {
setRunning(false)
}
}
const handleRetry = async (id: number) => {
setNotice(null)
try {
const res = await retryIngestFailure(id)
setNotice({ ok: true, text: res.message })
await load()
} catch {
setNotice({ ok: false, text: '重试操作失败' })
}
}
const pendingFailures = failures.filter(f => f.status === 'pending' || f.status === 'retrying')
return (
<div className="space-y-6">
<SectionHeader
title="数据管线"
description="采集失败重试、数据质量检查与监控"
action={
<button onClick={handleRunCheck} disabled={running} className="btn btn-sm">
{running ? <><Spinner /> </> : '运行质量检查'}
</button>
}
/>
{notice && (
<Alert kind={notice.ok ? 'ok' : 'error'} title={notice.text} onClose={() => setNotice(null)} />
)}
{error && <Alert kind="error" title="加载失败" message={error} onClose={() => setError(null)} />}
{loading && (
<div className="flex justify-center py-12"><Spinner /></div>
)}
{!loading && (
<>
{/* 采集失败记录 */}
<Card>
<CardHeader
title="采集失败记录"
description={pendingFailures.length > 0 ? `${pendingFailures.length} 条待处理` : '暂无待处理失败记录'}
/>
<CardBody className="px-0 sm:px-0">
{failures.length === 0 ? (
<p className="py-8 text-center text-xs text-ink-400"></p>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[640px] text-sm">
<thead>
<tr className="border-b border-ink-200 text-left text-ink-500">
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
</tr>
</thead>
<tbody>
{failures.map(f => (
<tr key={f.id} className="border-b border-ink-100 hover:bg-paper-100">
<td className="px-4 py-2 text-xs text-ink-700">{f.source}</td>
<td className="px-4 py-2 text-xs text-ink-600">{f.entity_type}</td>
<td className="px-4 py-2 text-xs text-ink-600">{f.error_type}</td>
<td className="px-4 py-2 text-xs tabular-nums text-ink-500">{f.retry_count}</td>
<td className="px-4 py-2">
<Badge status={f.status === 'resolved' ? 'success' : f.status === 'pending' ? 'warning' : 'info'}>
{f.status}
</Badge>
</td>
<td className="px-4 py-2">
{(f.status === 'pending' || f.status === 'retrying') && (
<button onClick={() => handleRetry(f.id)} className="btn btn-sm">
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardBody>
</Card>
{/* 数据质量检查 */}
<Card>
<CardHeader title="数据质量检查" description="最近 20 条检查结果" />
<CardBody className="px-0 sm:px-0">
{quality?.checks.length === 0 ? (
<p className="py-8 text-center text-xs text-ink-400">,</p>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[560px] text-sm">
<thead>
<tr className="border-b border-ink-200 text-left text-ink-500">
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
</tr>
</thead>
<tbody>
{quality?.checks.map(c => (
<tr key={c.id} className="border-b border-ink-100 hover:bg-paper-100">
<td className="px-4 py-2 text-xs text-ink-700">{c.check_name}</td>
<td className="px-4 py-2 text-xs text-ink-600">{c.entity_type}</td>
<td className="px-4 py-2">
<Badge status={c.passed ? 'success' : 'error'}>
{c.passed ? '通过' : '未通过'}
</Badge>
</td>
<td className="px-4 py-2">
<Badge status={c.severity === 'warning' ? 'warning' : c.severity === 'error' ? 'error' : 'info'}>
{c.severity}
</Badge>
</td>
<td className="px-4 py-2 text-2xs text-ink-400">
{c.checked_at ? new Date(c.checked_at).toLocaleString('zh-CN', { hour12: false }) : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardBody>
</Card>
</>
)}
</div>
)
}
+81 -1
View File
@@ -3,11 +3,14 @@ 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'
@@ -34,6 +37,8 @@ export default function DataSourcesPage() {
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)
@@ -70,11 +75,24 @@ export default function DataSourcesPage() {
}
}, [])
// Key Ring 状态(只读)
const loadKeyRing = useCallback(async () => {
setRingLoading(true)
try {
setKeyRing(await fetchKeyRingStatus())
} catch {
/* ignore */
} finally {
setRingLoading(false)
}
}, [])
useEffect(() => {
loadSources()
loadIngest()
loadStats()
}, [loadSources, loadIngest, loadStats])
loadKeyRing()
}, [loadSources, loadIngest, loadStats, loadKeyRing])
async function handleTest(sourceName: string) {
setTestingSource(sourceName)
@@ -160,6 +178,17 @@ export default function DataSourcesPage() {
}
}
async function handleResetCooldown() {
if (!window.confirm('确定重置所有 key 的冷却状态?这可能使被限流的 key 立即恢复请求。')) return
try {
const res = await resetKeyRingCooldown()
setKeyRing(res.stats)
setRowNotice({ key: "__ring", ok: true, text: res.message })
} catch (err) {
setRowNotice({ key: "__ring", ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '重置失败' })
}
}
return (
<div className="space-y-6">
@@ -252,6 +281,57 @@ export default function DataSourcesPage() {
</div>
)}
{/* API Key 轮换环状态 */}
<Card>
<CardHeader
title="API Key 轮换环"
description={keyRing?.has_multiple
? `已配置 ${keyRing.total} 个 key,遇到限流(429)自动切换;冷却 ${keyRing.cooldown_seconds}s`
: '当前仅 1 个 key,无法轮换。建议配置多个 key 以提高限流容忍度'
}
action={
<button
onClick={handleResetCooldown}
disabled={ringLoading}
className="btn-sm btn-outline"
>
</button>
}
/>
<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>
+5 -3
View File
@@ -10,7 +10,7 @@
import { useCallback, useEffect, useState } from 'react'
import { fetchEvalSummary, fetchLeagues } from '../dal'
import type { EvalSummary } from '../types'
import { Card, CardBody, CardHeader, StatCard, Badge, DataTable, Alert, Spinner, EmptyState } from '../components'
import { Card, CardBody, CardHeader, StatCard, Badge, DataTable, Alert, Spinner, EmptyText } from '../components'
interface Filters {
provider: string
@@ -155,7 +155,7 @@ export default function EvalPage() {
<Card>
<CardHeader
title="准确率对比"
description="按 provider × 模型 × prompt_version 聚合,仅统计有效预测"
description={!loading && summary.length > 0 ? `${summary.length}` : "按 provider × 模型 × prompt_version 聚合,仅统计有效预测"}
/>
<CardBody>
{loading ? (
@@ -163,8 +163,9 @@ export default function EvalPage() {
<Spinner />
</div>
) : summary.length === 0 ? (
<EmptyState text="暂无评估数据,请调整筛选条件或先完成预测与结算" />
<EmptyText text="暂无评估数据,请调整筛选条件或先完成预测与结算" />
) : (
<div className="overflow-x-auto">
<DataTable
columns={[
{ key: 'provider', label: '提供商' },
@@ -202,6 +203,7 @@ export default function EvalPage() {
rowKey={(row: any) => `${row.provider}-${row.model}-${row.prompt_version ?? ''}`}
emptyText="暂无评估数据"
/>
</div>
)}
</CardBody>
</Card>
+19 -1
View File
@@ -52,6 +52,24 @@ export default function LogsPage() {
load()
}, [load])
const scrollRef = useRef<HTMLDivElement>(null)
const userScrolledUp = useRef(false)
// 检测用户是否向上滚动过
const handleScroll = () => {
const el = scrollRef.current
if (!el) return
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 50
userScrolledUp.current = !atBottom
}
// 加载后自动滚动到底部(仅当用户未向上滚动时)
useEffect(() => {
if (autoRefresh && !userScrolledUp.current && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [entries, autoRefresh])
// 自动刷新
useEffect(() => {
if (timerRef.current) clearInterval(timerRef.current)
@@ -123,7 +141,7 @@ export default function LogsPage() {
) : entries.length === 0 ? (
<p className="py-10 text-center text-xs text-ink-400"></p>
) : (
<div>
<div ref={scrollRef} onScroll={handleScroll} className="max-h-[60vh] overflow-y-auto">
{entries.map((e, i) => {
const badge = LEVEL_BADGE[e.level] ?? { status: 'info' as const, text: e.level }
return (
@@ -0,0 +1,316 @@
/**
* Admin 后台 - 预测历史页面(报刊风)
*
* 功能:
* - 查看预测记录列表(支持状态筛选)
* - 实际比分自动从比赛赛果填充
* - 赛后一键结算,供准确率评估
*/
import { useCallback, useEffect, useState } from 'react'
import { fetchPredictions, settlePrediction } from '../dal'
import type { Prediction } from '../types'
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
import TeamSideTag from '../../components/TeamSideTag'
const OUTCOME_LABEL: Record<string, string> = { '1': '主胜', X: '平局', '2': '客胜' }
function fmtDate(s?: string | null): string {
if (!s) return '—'
const d = new Date(s)
return isNaN(d.getTime()) ? s : `${d.getMonth() + 1}/${d.getDate()}`
}
export default function PredictionHistoryPage() {
const [predictions, setPredictions] = useState<Prediction[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [filter, setFilter] = useState<'all' | 'settled' | 'unsettled'>('all')
const [expandedId, setExpandedId] = useState<number | null>(null)
const [settlingId, setSettlingId] = useState<number | null>(null)
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const list = await fetchPredictions(200)
setPredictions(list)
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
const handleSettle = async (p: Prediction) => {
const match = (p as any).match
if (!match || match.home_goals == null || match.away_goals == null) return
setSettlingId(p.id)
try {
await settlePrediction(p.id, match.home_goals, match.away_goals)
await load()
} catch (err) {
console.error('结算失败:', err)
} finally {
setSettlingId(null)
}
}
const filtered = predictions.filter(p => {
if (filter === 'settled') return p.settled
if (filter === 'unsettled') return !p.settled
return true
})
const unsettledCount = predictions.filter(p => !p.settled).length
const settledWithScore = predictions.filter(p => p.settled && p.actual_home_goals != null && p.actual_away_goals != null)
const hitCount = settledWithScore.filter(p => {
const actual = p.actual_home_goals! > p.actual_away_goals! ? '1' : p.actual_home_goals! < p.actual_away_goals! ? '2' : 'X'
return actual === p.pred_1x2
}).length
const accuracy = settledWithScore.length > 0 ? Math.round(hitCount / settledWithScore.length * 100) : 0
return (
<div className="space-y-6">
<SectionHeader
title="预测历史"
description={`${predictions.length} 条记录${unsettledCount > 0 ? `, ${unsettledCount} 条待结算` : ''}`}
/>
{/* 统计概览 */}
{settledWithScore.length > 0 && (
<div className="grid gap-4 sm:grid-cols-3">
<Card>
<CardBody className="text-center">
<p className="text-2xl font-bold text-ink-900">{settledWithScore.length}</p>
<p className="text-xs text-ink-500"></p>
</CardBody>
</Card>
<Card>
<CardBody className="text-center">
<p className="text-2xl font-bold text-emerald-700">{hitCount}</p>
<p className="text-xs text-ink-500"></p>
</CardBody>
</Card>
<Card>
<CardBody className="text-center">
<p className="text-2xl font-bold text-press">{accuracy}%</p>
<p className="text-xs text-ink-500">1X2 </p>
</CardBody>
</Card>
</div>
)}
{/* 筛选 */}
<div className="flex gap-2">
{([
{ v: 'all', label: '全部' },
{ v: 'unsettled', label: '待结算' },
{ v: 'settled', label: '已结算' },
] as const).map(opt => (
<button
key={opt.v}
onClick={() => setFilter(opt.v)}
className={`btn btn-sm ${filter === opt.v ? 'btn-solid' : ''}`}
>
{opt.label}
</button>
))}
</div>
{loading && (
<div className="flex justify-center py-12"><Spinner /></div>
)}
{error && (
<Alert kind="error" title="加载失败" message={error} onClose={() => setError(null)} />
)}
{!loading && !error && filtered.length === 0 && (
<div className="empty-state">
<p className="empty-state-title"></p>
<p className="empty-state-sub"></p>
</div>
)}
{/* 预测列表 */}
{!loading && !error && filtered.length > 0 && (
<Card>
<CardBody className="px-0 sm:px-0">
<div className="overflow-x-auto">
<table className="w-full min-w-[700px] text-sm">
<thead>
<tr className="border-b border-ink-200 text-left text-ink-500">
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium">1X2</th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
</tr>
</thead>
<tbody>
{filtered.map(p => {
const match = (p as any).match
const matchDate = match?.match_date
const homeName = match?.home_team_zh || match?.home_team || '?'
const awayName = match?.away_team_zh || match?.away_team || '?'
const actualHome = match?.home_goals
const actualAway = match?.away_goals
const hasActual = actualHome != null && actualAway != null
const isExpanded = expandedId === p.id
const predHit = p.settled && hasActual
? (actualHome! > actualAway! ? '1' : actualHome! < actualAway! ? '2' : 'X') === p.pred_1x2
: null
return (
<tr key={p.id} className="border-b border-ink-100 hover:bg-paper-100">
<td className="px-4 py-3 text-2xs text-ink-400">{fmtDate(matchDate)}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1 text-xs text-ink-800">
<TeamSideTag side="home" />
<span className="truncate max-w-[100px]">{homeName}</span>
</div>
<div className="flex items-center gap-1 text-xs text-ink-600 mt-0.5">
<TeamSideTag side="away" />
<span className="truncate max-w-[100px]">{awayName}</span>
</div>
</td>
<td className="px-4 py-3 font-serif font-bold text-ink-900">
{p.pred_home_goals != null && p.pred_away_goals != null
? `${p.pred_home_goals} : ${p.pred_away_goals}` : '—'}
</td>
<td className="px-4 py-3">
{hasActual ? (
<span className={`font-serif font-bold ${p.settled ? 'text-ink-900' : 'text-ink-400'}`}>
{actualHome} : {actualAway}
</span>
) : (
<span className="text-2xs text-ink-300"></span>
)}
</td>
<td className="px-4 py-3">
{p.pred_1x2 ? (
<span className={`inline-block border px-1.5 py-0.5 text-2xs ${
predHit === true ? 'border-emerald-300 text-emerald-700 bg-emerald-50' :
predHit === false ? 'border-press text-press bg-press-wash' :
'border-ink-200 text-ink-600'
}`}>
{OUTCOME_LABEL[p.pred_1x2] ?? p.pred_1x2}
{predHit === true && ' ✓'}
{predHit === false && ' ✗'}
</span>
) : '—'}
</td>
<td className="px-4 py-3">
{p.settled ? (
<Badge status="success"></Badge>
) : hasActual ? (
<Badge status="info"></Badge>
) : (
<Badge status="warning"></Badge>
)}
</td>
<td className="px-4 py-3">
<div className="flex gap-2">
{!p.settled && hasActual && (
<button
onClick={() => handleSettle(p)}
disabled={settlingId === p.id}
className="btn btn-sm btn-solid"
>
{settlingId === p.id ? <Spinner /> : '结算'}
</button>
)}
<button
onClick={() => setExpandedId(isExpanded ? null : p.id)}
className="btn btn-sm"
>
{isExpanded ? '收起' : '详情'}
</button>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</CardBody>
</Card>
)}
{/* 展开详情 */}
{expandedId && (() => {
const p = predictions.find(pr => pr.id === expandedId)
if (!p) return null
const match = (p as any).match
const reports = p.agent_outputs ?? []
return (
<Card>
<CardHeader title="预测详情" />
<CardBody className="space-y-4">
<div className="flex items-center gap-2 text-sm text-ink-700">
<span>{fmtDate(match?.match_date)}</span>
<span className="text-ink-300">|</span>
<TeamSideTag side="home" />
<span>{match?.home_team_zh || match?.home_team || '?'}</span>
<span className="text-ink-400">vs</span>
<TeamSideTag side="away" />
<span>{match?.away_team_zh || match?.away_team || '?'}</span>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div className="border-t-2 border-ink-900 pt-3">
<p className="text-2xs text-ink-400"></p>
<p className="mt-1 font-serif text-xl font-bold text-ink-900">
{p.pred_home_goals != null && p.pred_away_goals != null
? `${p.pred_home_goals} : ${p.pred_away_goals}` : '—'}
</p>
</div>
<div className="border-t-2 border-ink-900 pt-3">
<p className="text-2xs text-ink-400"></p>
<p className="mt-1 font-serif text-xl font-bold text-ink-900">
{match?.home_goals != null && match?.away_goals != null
? `${match.home_goals} : ${match.away_goals}` : '—'}
</p>
</div>
<div className="border-t-2 border-ink-900 pt-3">
<p className="text-2xs text-ink-400"></p>
<p className="mt-1 font-serif text-xl font-bold text-ink-900">
{p.subjective_confidence != null ? `${(p.subjective_confidence * 100).toFixed(0)}%` : '—'}
</p>
</div>
</div>
{p.reasoning && (
<div>
<h4 className="section-head mb-2"></h4>
<blockquote className="border-l-2 border-press pl-4">
<p className="whitespace-pre-wrap font-serif text-sm leading-loose text-ink-700">{p.reasoning}</p>
</blockquote>
</div>
)}
{reports.length > 0 && (
<div>
<h4 className="section-head mb-2"></h4>
<div className="space-y-2">
{reports.map((r) => (
<div key={r.agent} className="border-b border-ink-100 pb-2 last:border-b-0">
<p className="text-xs font-medium text-ink-700">{r.agent}</p>
<p className="mt-0.5 text-xs text-ink-500">{r.analysis || '—'}</p>
</div>
))}
</div>
</div>
)}
</CardBody>
</Card>
)
})()}
</div>
)
}
+24 -11
View File
@@ -14,6 +14,7 @@ import { triggerPrediction, fetchPredictions, fetchMatches, settlePrediction } f
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: '历史交锋分析专家',
@@ -37,7 +38,7 @@ export default function PredictionsPage() {
const [matches, setMatches] = useState<Match[]>([])
const [predictions, setPredictions] = useState<Prediction[]>([])
const [matchId, setMatchId] = useState('')
const [mode] = useState<'multi'>('multi')
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)
@@ -142,7 +143,7 @@ export default function PredictionsPage() {
{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 ? new Date(m.match_date).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' }) : '—'})
({m.match_date?.slice(5, 10)})
</option>
))}
</select>
@@ -150,12 +151,14 @@ export default function PredictionsPage() {
<div>
<label className="mb-1.5 block text-xs text-ink-500"></label>
<input
type="text"
value="多专家 (5 路 + 终裁)"
readOnly
className="field w-full bg-paper-100 text-ink-500"
/>
<select
value={mode}
onChange={e => setMode(e.target.value as 'single' | 'multi')}
className="field w-full"
>
<option value="multi"> (5 + ,)</option>
<option value="single"> ()</option>
</select>
</div>
{error && <Alert kind="error" title="预测失败" message={error} onClose={() => setError(null)} />}
@@ -288,8 +291,14 @@ export default function PredictionsPage() {
<Badge status="success"></Badge>
) : (
<Badge status="pending"></Badge>
)}
<span className="text-2xs tabular-nums text-ink-400">{fmtTime(p.created_at)}</span>
)}
{p.status === 'degraded' && (
<Badge status="error">·</Badge>
)}
{p.status === 'failed' && (
<Badge status="error"></Badge>
)}
<span className="text-2xs tabular-nums text-ink-400">{fmtTime(p.created_at)}</span>
<svg
viewBox="0 0 20 20"
className="h-3 w-3 self-center text-ink-300 transition-transform group-open:rotate-90"
@@ -303,7 +312,7 @@ export default function PredictionsPage() {
<div className="space-y-3 px-4 pb-4 pl-8 sm:px-6 sm:pl-9">
<p className="text-2xs text-ink-500">
`多专家 · ${okAgents.length}/${p.agent_outputs?.length ?? 0} 路有效`
{p.mode === 'multi' ? `多专家 · ${okAgents.length}/${p.agent_outputs?.length ?? 0} 路有效` : '单次模式'}
{p.model && <span className="ml-2 font-mono">{p.model}</span>}
</p>
@@ -315,6 +324,10 @@ export default function PredictionsPage() {
</blockquote>
)}
{p.agent_weights && Object.keys(p.agent_weights).length > 0 && (
<AgentWeightsBar weights={p.agent_weights} okCount={okAgents.length} />
)}
{p.agent_outputs && p.agent_outputs.length > 0 && (
<ul className="space-y-1">
{p.agent_outputs.map((a, i) => (
+496
View File
@@ -0,0 +1,496 @@
/**
* Admin 后台 - 统一设置页(报刊风)
*
* 合并原「数据源」「LLM 配置」「系统配置」三页:
* 1. 数据源 — bzzoiro API Key + Key Ring 状态
* 2. LLM — 连接配置 + 使用统计 + 专家独立配置
* 3. 认证 — 修改密码
* 4. 系统 — 配置查看 + 修改指南
*/
import { useEffect, useState, useCallback } from 'react'
import {
fetchSettings, updateSetting, clearSetting,
testLLMConnection, fetchLLMUsageStats, fetchLLMModels,
fetchKeyRingStatus, resetKeyRingCooldown,
fetchSchedules, createSchedule, updateSchedule, deleteSchedule, runScheduleNow,
} from '../dal'
import type { ScheduleItem } from '../dal'
import { changePassword, fetchAuthState, UNAUTHORIZED_EVENT } from '../api'
import type { LLMUsageStats, DataSourceSetting } from '../types'
import type { KeyRingStatusResponse } from '../dal'
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components'
import SettingRow from '../SettingRow'
import AgentLLMCard from '../AgentLLMCard'
const DATA_SOURCE_KEYS = ['BZZOIRO_KEY', 'BZZOIRO_BASE']
const LLM_KEYS = ['LLM_API_KEY', 'LLM_BASE_URL', 'LLM_MODEL']
export default function SettingsPage() {
const [allSettings, setAllSettings] = useState<DataSourceSetting[]>([])
const [settingsLoading, setSettingsLoading] = useState(true)
const [editingKey, setEditingKey] = useState<string | null>(null)
const [busyKey, setBusyKey] = useState<string | null>(null)
const [rowNotice, setRowNotice] = useState<{ key: string; ok: boolean; text: string } | null>(null)
// LLM
const [llmStats, setLlmStats] = useState<LLMUsageStats | null>(null)
const [llmLoading, setLlmLoading] = useState(true)
const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null)
// Key Ring
const [keyRing, setKeyRing] = useState<KeyRingStatusResponse | null>(null)
const [ringLoading, setRingLoading] = useState(false)
// 密码
const [passwordOrigin, setPasswordOrigin] = useState<'db' | 'env' | 'none' | null>(null)
const [currentPwd, setCurrentPwd] = useState('')
const [newPwd, setNewPwd] = useState('')
const [confirmPwd, setConfirmPwd] = useState('')
const [pwdBusy, setPwdBusy] = useState(false)
const [pwdNotice, setPwdNotice] = useState<{ ok: boolean; text: string } | null>(null)
// 定时任务
const [schedules, setSchedules] = useState<ScheduleItem[]>([])
const [schedulesLoading, setSchedulesLoading] = useState(true)
const [scheduleNotice, setScheduleNotice] = useState<{ ok: boolean; text: string } | null>(null)
const [scheduleBusyId, setScheduleBusyId] = useState<string | null>(null)
// ── 数据加载 ──
const loadSettings = useCallback(async () => {
setSettingsLoading(true)
try {
const all = await fetchSettings()
setAllSettings(all)
} catch {
setAllSettings([])
} finally {
setSettingsLoading(false)
}
}, [])
const loadLlmStats = useCallback(async () => {
setLlmLoading(true)
try {
setLlmStats(await fetchLLMUsageStats())
} catch {
setLlmStats(null)
} finally {
setLlmLoading(false)
}
}, [])
const loadKeyRing = useCallback(async () => {
setRingLoading(true)
try {
setKeyRing(await fetchKeyRingStatus())
} catch {
setKeyRing(null)
} finally {
setRingLoading(false)
}
}, [])
useEffect(() => {
loadSettings()
loadLlmStats()
loadKeyRing()
fetchAuthState().then(s => setPasswordOrigin(s.password_origin ?? null)).catch(() => {})
fetchSchedules().then(setSchedules).catch(() => []).finally(() => setSchedulesLoading(false))
}, [loadSettings, loadLlmStats, loadKeyRing])
const dataSourceSettings = allSettings.filter(s => DATA_SOURCE_KEYS.includes(s.key))
const llmSettings = allSettings.filter(s => LLM_KEYS.includes(s.key))
// ── 操作 ──
const handleSave = async (key: string, value: string) => {
setBusyKey(key)
setRowNotice(null)
try {
await updateSetting(key, value)
setRowNotice({ key, ok: true, text: '已保存,立即生效' })
setEditingKey(null)
await loadSettings()
if (key === 'BZZOIRO_KEY') await loadKeyRing()
} catch (err) {
setRowNotice({ key, ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '保存失败' })
} finally {
setBusyKey(null)
}
}
const handleClear = async (key: string) => {
setBusyKey(key)
setRowNotice(null)
try {
await clearSetting(key)
setRowNotice({ key, ok: true, text: '已清除数据库覆盖,回落 .env 默认值' })
await loadSettings()
} catch (err) {
setRowNotice({ key, ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '清除失败' })
} finally {
setBusyKey(null)
}
}
// ── 定时任务操作 ──
const handleToggleSchedule = async (s: ScheduleItem) => {
setScheduleBusyId(s.id)
setScheduleNotice(null)
try {
await updateSchedule(s.id, { enabled: !s.enabled })
setScheduleNotice({ ok: true, text: `${!s.enabled ? '启用' : '禁用'}任务「${s.id}` })
setSchedules(prev => prev.map(x => x.id === s.id ? { ...x, enabled: !x.enabled } : x))
} catch {
setScheduleNotice({ ok: false, text: '操作失败' })
} finally {
setScheduleBusyId(null)
}
}
const handleRunSchedule = async (s: ScheduleItem) => {
setScheduleBusyId(s.id)
setScheduleNotice(null)
try {
await runScheduleNow(s.id)
setScheduleNotice({ ok: true, text: `任务「${s.id}」已启动,请在日志页查看进度` })
} catch {
setScheduleNotice({ ok: false, text: '启动失败' })
} finally {
setScheduleBusyId(null)
}
}
const handleDeleteSchedule = async (s: ScheduleItem) => {
setScheduleBusyId(s.id)
setScheduleNotice(null)
try {
await deleteSchedule(s.id)
setScheduleNotice({ ok: true, text: `已删除任务「${s.id}` })
setSchedules(prev => prev.filter(x => x.id !== s.id))
} catch {
setScheduleNotice({ ok: false, text: '删除失败' })
} finally {
setScheduleBusyId(null)
}
}
const detectLLMModels = useCallback(async (): Promise<string[]> => {
const r = await fetchLLMModels()
if (!r.ok) throw new Error(r.detail)
return r.models
}, [])
const handleTest = async () => {
setTesting(true)
setTestResult(null)
try {
await testLLMConnection()
setTestResult({ success: true, message: 'LLM 连接测试成功' })
} catch (err: unknown) {
setTestResult({ success: false, message: err instanceof Error ? err.message : 'LLM 连接测试失败' })
} finally {
setTesting(false)
}
}
const handleResetCooldown = async () => {
if (!window.confirm('确定重置所有 key 的冷却状态?这可能使被限流的 key 立即恢复请求。')) return
try {
const res = await resetKeyRingCooldown()
setKeyRing(res.stats)
setRowNotice({ key: "__ring", ok: true, text: res.message })
} catch (err) {
setRowNotice({ key: "__ring", ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '重置失败' })
}
}
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault()
setPwdNotice(null)
if (newPwd !== confirmPwd) {
setPwdNotice({ ok: false, text: '两次输入的新密码不一致' })
return
}
setPwdBusy(true)
try {
const res = await changePassword(currentPwd, newPwd)
setPwdNotice({ ok: true, text: res.message })
setTimeout(() => window.dispatchEvent(new CustomEvent(UNAUTHORIZED_EVENT)), 1500)
} catch (err) {
setPwdNotice({ ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '修改失败' })
} finally {
setPwdBusy(false)
}
}
return (
<div className="space-y-8">
<SectionHeader
title="系统设置"
description="数据源、LLM、认证等全部配置。保存到数据库并立即生效,优先于 .env。"
/>
{/* ── 1. 数据源 ── */}
<section>
<h2 className="section-head mb-3"></h2>
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader
title="Bzzoiro API"
description="赛程 / 比分 / 积分榜 / 比赛统计的唯一数据源"
action={<button onClick={loadSettings} disabled={settingsLoading} className="btn btn-sm">{settingsLoading ? <><Spinner /> </> : '刷新'}</button>}
/>
<CardBody>
{settingsLoading ? (
<div className="space-3">{dataSourceSettings.map((_, i) => <SkeletonBlock key={i} className="h-9 w-full" />)}</div>
) : (
dataSourceSettings.map(setting => (
<SettingRow
key={setting.key}
setting={setting}
editing={editingKey === setting.key}
busy={busyKey === setting.key}
onEdit={() => { setEditingKey(setting.key); setRowNotice(null) }}
onCancel={() => setEditingKey(null)}
onSave={v => handleSave(setting.key, v)}
onClear={() => handleClear(setting.key)}
/>
))
)}
{rowNotice && !rowNotice.key.startsWith('__') && dataSourceSettings.some(s => s.key === rowNotice.key) && (
<div className="mt-3"><Alert kind={rowNotice.ok ? 'ok' : 'error'} title={rowNotice.text} /></div>
)}
</CardBody>
</Card>
{/* Key Ring */}
<Card>
<CardHeader
title="API Key 轮换环"
description={keyRing?.has_multiple ? `已配置 ${keyRing.total} 个 key,遇限流自动切换` : '当前仅 1 个 key,无法轮换'}
action={<button onClick={handleResetCooldown} disabled={ringLoading} className="btn-sm btn-outline"></button>}
/>
<CardBody>
{keyRing && keyRing.total > 0 ? (
<div className="space-y-2">
{keyRing.keys.map((k, i) => {
const isBlocked = k.blocked_remaining > 0
return (
<div key={i} className={`flex items-center justify-between gap-3 border-b border-ink-100 py-2 last:border-b-0 ${isBlocked ? 'opacity-70' : ''}`}>
<div className="flex items-center gap-2">
<span className={`inline-block h-2 w-2 rounded-full ${isBlocked ? 'bg-amber-500' : 'bg-emerald-500'}`} />
<span className="font-mono text-xs text-ink-700">{k.masked}</span>
{i === keyRing.active_index && <span className="rounded bg-ink-900 px-1.5 py-0.5 text-2xs text-paper-500"></span>}
</div>
<span className={`text-2xs tabular-nums ${isBlocked ? 'text-amber-600' : 'text-ink-400'}`}>
{isBlocked ? `冷却中 ${k.blocked_remaining.toFixed(0)}s` : '可用'}
</span>
</div>
)
})}
</div>
) : (
<p className="text-xs text-ink-400"> key </p>
)}
</CardBody>
</Card>
</div>
</section>
{/* ── 2. LLM ── */}
<section>
<h2 className="section-head mb-3"></h2>
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader
title="连接配置"
description="OpenAI 兼容接口(DeepSeek / 智谱 / 通义等)"
action={<button onClick={loadSettings} disabled={settingsLoading} className="btn btn-sm">{settingsLoading ? <><Spinner /> </> : '刷新'}</button>}
/>
<CardBody>
{settingsLoading ? (
<div className="space-y-3">{llmSettings.map((_, i) => <SkeletonBlock key={i} className="h-9 w-full" />)}</div>
) : (
llmSettings.map(setting => (
<SettingRow
key={setting.key}
setting={setting}
editing={editingKey === setting.key}
busy={busyKey === setting.key}
onEdit={() => { setEditingKey(setting.key); setRowNotice(null) }}
onCancel={() => setEditingKey(null)}
onSave={v => handleSave(setting.key, v)}
onClear={() => handleClear(setting.key)}
detectModels={setting.key === 'LLM_MODEL' ? detectLLMModels : undefined}
/>
))
)}
{rowNotice && !rowNotice.key.startsWith('__') && llmSettings.some(s => s.key === rowNotice.key) && (
<div className="mt-3"><Alert kind={rowNotice.ok ? 'ok' : 'error'} title={rowNotice.text} /></div>
)}
{testResult && (
<div className="mt-4">
<Alert kind={testResult.success ? 'ok' : 'error'} title={testResult.success ? '连接正常' : '连接失败'} message={testResult.success ? undefined : testResult.message} />
</div>
)}
<button onClick={handleTest} disabled={testing} className="btn btn-sm mt-4 w-full">
{testing ? <><Spinner /> </> : '测试 LLM 连接'}
</button>
<p className="mt-2 text-center text-2xs text-ink-400"> LLM ,</p>
</CardBody>
</Card>
<Card>
<CardHeader title="使用统计" description="从最近预测记录聚合" action={<button onClick={loadLlmStats} disabled={llmLoading} className="btn btn-sm">{llmLoading ? <><Spinner /> </> : '刷新'}</button>} />
<CardBody>
{llmLoading ? (
<div className="space-y-3"><SkeletonBlock className="h-16 w-full" /><SkeletonBlock className="h-16 w-full" /></div>
) : llmStats ? (
<div className="grid grid-cols-3 gap-4">
<div className="border-t-2 border-ink-900 pt-3 text-center">
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">{llmStats.total_predictions}</div>
<div className="mt-1 text-2xs text-ink-400"></div>
</div>
<div className="border-t-2 border-ink-900 pt-3 text-center">
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">{llmStats.avg_latency_ms > 0 ? `${(llmStats.avg_latency_ms / 1000).toFixed(1)}s` : '—'}</div>
<div className="mt-1 text-2xs text-ink-400"></div>
</div>
<div className="border-t-2 border-press pt-3 text-center">
<div className="font-serif text-2xl font-bold tabular-nums text-press">{llmStats.success_rate.toFixed(0)}%</div>
<div className="mt-1 text-2xs text-ink-400"></div>
</div>
</div>
) : (
<p className="py-6 text-center text-xs text-ink-400">使</p>
)}
</CardBody>
</Card>
</div>
<div className="mt-6">
<AgentLLMCard />
</div>
</section>
{/* ── 3. 认证 ── */}
<section>
<h2 className="section-head mb-3"></h2>
<Card>
<CardHeader title="修改密码" description="密码即会话签名密钥,修改后所有已登录会话失效,需重新登录" />
<CardBody>
{passwordOrigin && (
<p className="mb-3 flex items-center gap-2 text-2xs text-ink-500">
:
{passwordOrigin === 'db' ? <Badge status="success">(scrypt )</Badge>
: passwordOrigin === 'env' ? <Badge status="info">.env </Badge>
: <Badge status="error"></Badge>}
</p>
)}
<form onSubmit={handleChangePassword} className="space-y-3">
<div className="grid gap-3 sm:grid-cols-3">
<div>
<label className="mb-1 block text-2xs text-ink-500"></label>
<input type="password" value={currentPwd} onChange={e => setCurrentPwd(e.target.value)} autoComplete="current-password" className="field w-full" />
</div>
<div>
<label className="mb-1 block text-2xs text-ink-500">( 8 )</label>
<input type="password" value={newPwd} onChange={e => setNewPwd(e.target.value)} autoComplete="new-password" className="field w-full" />
</div>
<div>
<label className="mb-1 block text-2xs text-ink-500"></label>
<input type="password" value={confirmPwd} onChange={e => setConfirmPwd(e.target.value)} autoComplete="new-password" className="field w-full" />
</div>
</div>
{pwdNotice && <Alert kind={pwdNotice.ok ? 'ok' : 'error'} title={pwdNotice.text} />}
<div className="flex items-center justify-between gap-2">
<p className="text-2xs text-ink-400">退</p>
<button type="submit" disabled={pwdBusy || !currentPwd || !newPwd || !confirmPwd} className="btn btn-solid btn-sm flex-shrink-0">
{pwdBusy ? <><Spinner /> </> : '修改密码'}
</button>
</div>
</form>
</CardBody>
</Card>
</section>
{/* ── 4. 定时任务 ── */}
<section>
<h2 className="section-head mb-3"></h2>
<Card>
<CardHeader
title="采集调度"
description="配置 cron 表达式定时触发采集任务"
action={
<button
onClick={() => {
createSchedule({ id: `schedule-${Date.now()}`, task: 'events', cron: '0 8 * * *', leagues: undefined, enabled: false })
.then(() => fetchSchedules().then(setSchedules))
}}
className="btn btn-sm"
>
+
</button>
}
/>
<CardBody>
{scheduleNotice && (
<div className="mb-3">
<Alert kind={scheduleNotice.ok ? 'ok' : 'error'} title={scheduleNotice.text} onClose={() => setScheduleNotice(null)} />
</div>
)}
{schedulesLoading ? (
<SkeletonBlock className="h-10 w-full" />
) : schedules.length === 0 ? (
<p className="py-6 text-center text-xs text-ink-400">,+ </p>
) : (
<div className="space-y-2">
{schedules.map(s => (
<div key={s.id} className="flex flex-col gap-2 border-b border-ink-100 py-3 last:border-b-0 sm:flex-row sm:items-center sm:justify-between">
<div className="flex-1 space-y-1">
<div className="flex items-center gap-2">
<span className={`inline-block h-2 w-2 rounded-full ${s.enabled ? 'bg-emerald-500' : 'bg-ink-300'}`} />
<span className="text-xs font-medium text-ink-800">{s.id}</span>
<Badge status={s.enabled ? 'success' : 'muted'}>{s.task}</Badge>
</div>
<p className="font-mono text-2xs text-ink-500">{s.cron}</p>
{s.last_run_at && (
<p className="text-2xs text-ink-400">
: {new Date(s.last_run_at).toLocaleString('zh-CN', { hour12: false })}
{s.last_status === 'success' ? ' ✓' : s.last_status === 'failed' ? ' ✗' : ''}
</p>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleRunSchedule(s)}
disabled={scheduleBusyId === s.id}
className="btn btn-sm"
>
{scheduleBusyId === s.id ? <Spinner /> : '立即执行'}
</button>
<button
onClick={() => handleToggleSchedule(s)}
disabled={scheduleBusyId === s.id}
className={`btn btn-sm ${s.enabled ? '' : 'btn-solid'}`}
>
{scheduleBusyId === s.id ? <Spinner /> : (s.enabled ? '禁用' : '启用')}
</button>
<button
onClick={() => handleDeleteSchedule(s)}
disabled={scheduleBusyId === s.id}
className="btn btn-sm btn-danger"
>
{scheduleBusyId === s.id ? <Spinner /> : '删除'}
</button>
</div>
</div>
))}
</div>
)}
</CardBody>
</Card>
</section>
</div>
)
}
+8 -8
View File
@@ -9,14 +9,14 @@ import { Navigate } from 'react-router-dom'
import AdminLayout from './AdminLayout'
import Dashboard from './pages/Dashboard'
import CollectionPage from './pages/Collection'
import PredictionsPage from './pages/Predictions'
import DataCompletenessPage from './pages/DataCompleteness'
import PredictionsPage from './pages/PredictionHistory'
import BacktestPage from './pages/Backtest'
import MonitoringPage from './pages/Monitoring'
import DataSourcesPage from './pages/DataSources'
import LLMConfigPage from './pages/LLMConfig'
import ConfigPage from './pages/Config'
import SettingsPage from './pages/Settings'
import LogsPage from './pages/Logs'
import EvalPage from './pages/EvalPage'
import DataPipelinePage from './pages/DataPipeline'
export const adminRoutes = [
{
@@ -25,14 +25,14 @@ export const adminRoutes = [
children: [
{ index: true, element: <Dashboard /> },
{ path: 'collection', element: <CollectionPage /> },
{ path: 'data-completeness', element: <DataCompletenessPage /> },
{ path: 'data-pipeline', element: <DataPipelinePage /> },
{ path: 'predictions', element: <PredictionsPage /> },
{ path: 'backtest', element: <BacktestPage /> },
{ path: 'monitoring', element: <MonitoringPage /> },
{ path: 'data-sources', element: <DataSourcesPage /> },
{ path: 'llm-config', element: <LLMConfigPage /> },
{ path: 'config', element: <ConfigPage /> },
{ path: 'settings', element: <SettingsPage /> },
{ path: 'logs', element: <LogsPage /> },
{ path: 'eval', element: <EvalPage /> },
{ path: 'eval', element: <EvalPage /> },
{ path: '*', element: <Navigate to="/admin" replace /> },
],
},
+46 -3
View File
@@ -17,7 +17,8 @@ export interface HealthStatus {
export interface DashboardStats {
leagues: League[]
total_matches: number
// 比赛总量已移除: 用 fetchAdminStats()(/admin/stats)的精确 COUNT,
// 不要再用列表 items.length 近似(上限 100 会严重失真)
total_predictions: number
health: string
db_tables: { name: string; row_count: number; size_mb: number; last_updated: string | null }[]
@@ -74,6 +75,8 @@ export interface Prediction {
subjective_confidence?: number | null
reasoning?: string | null
agent_outputs?: PredictionAgentOutput[] | null
agent_weights?: Record<string, number> | null
status?: 'success' | 'failed' | 'degraded'
created_at: string
actual_home_goals?: number | null
actual_away_goals?: number | null
@@ -91,9 +94,10 @@ export interface PredictRequest {
export interface CollectionRequest {
status?: string
source: 'bzzoiro' | 'understat' | 'injuries'
source: 'bzzoiro'
leagues?: string[]
league?: string
task?: 'events' | 'standings' | 'stats' | 'all'
limit?: number
season?: string
date_from?: string
date_to?: string
@@ -279,6 +283,20 @@ export interface IngestSourceStatus {
last_failure: IngestLastFailure | null
}
// ── 采集任务状态 ──────────────────────────────────────────────
export interface IngestJob {
id: string
task: string
params: Record<string, unknown>
status: 'pending' | 'running' | 'success' | 'failed'
result: Record<string, unknown> | null
error: string | null
created_at: string | null
started_at: string | null
finished_at: string | null
}
// ── 比赛详情 ─────────────────────────────────────────────────────
export interface MatchRecentPrediction {
@@ -318,9 +336,31 @@ export interface MatchDetailOut {
match_stage: string | null
home_xg: number | null
away_xg: number | null
stats: MatchStatsDetail | null
recent_predictions: MatchRecentPrediction[]
}
/** bzzoiro /events/{id}/stats/ 返回的详细比赛统计 */
export interface MatchStatsDetail {
home_xg: number | null
away_xg: number | null
home_shots: number | null
away_shots: number | null
home_shots_on_target: number | null
away_shots_on_target: number | null
home_corners: number | null
away_corners: number | null
home_possession: number | null
home_yellow_cards: number | null
away_yellow_cards: number | null
home_red_cards: number | null
away_red_cards: number | null
home_big_chances: number | null
away_big_chances: number | null
home_fouls: number | null
away_fouls: number | null
}
export interface TeamRecentMatch {
match_date: string | null
home_team: string | null
@@ -343,4 +383,7 @@ export interface AdminStats {
last_24h: number
last_7d: number
}
matches?: { total: number; finished: number }
stats?: { total: number }
standings?: { total: number }
}
+155
View File
@@ -0,0 +1,155 @@
/**
* Admin 后台 - 键盘快捷键: Cmd+K 命令面板
*
* 提供全局快捷键:
* Cmd/Ctrl+K — 打开命令面板(页面跳转)
* / — 聚焦搜索(日志页)
* r — 刷新当前页面数据(通用)
*/
import { useEffect, useState, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
interface CommandItem {
id: string
label: string
group: string
action: () => void
}
export function useCommandPalette(pages: Array<{ to: string; label: string; group: string }>) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const navigate = useNavigate()
const items: CommandItem[] = pages.map(p => ({
id: p.to,
label: p.label,
group: p.group,
action: () => { navigate(p.to); setOpen(false) },
}))
const filtered = query
? items.filter(i => i.label.toLowerCase().includes(query.toLowerCase()) || i.id.includes(query.toLowerCase()))
: items
useEffect(() => {
const handler = (e: KeyboardEvent) => {
// Cmd/Ctrl+K → 命令面板
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault()
setOpen(o => !o)
setQuery('')
}
// Escape → 关闭
if (e.key === 'Escape' && open) {
setOpen(false)
setQuery('')
}
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [open])
return { open, setOpen, query, setQuery, items: filtered }
}
/** 命令面板弹窗 */
export function CommandPalette({
open,
query,
setQuery,
items,
onClose,
}: {
open: boolean
query: string
setQuery: (q: string) => void
items: CommandItem[]
onClose: () => void
}) {
const [selected, setSelected] = useState(0)
// 重置选中项当列表变化
useEffect(() => { setSelected(0) }, [items.length])
useEffect(() => {
if (!open) return
const handler = (e: KeyboardEvent) => {
if (e.key === 'ArrowDown') { e.preventDefault(); setSelected(s => Math.min(s + 1, items.length - 1)) }
if (e.key === 'ArrowUp') { e.preventDefault(); setSelected(s => Math.max(s - 1, 0)) }
if (e.key === 'Enter' && items[selected]) { items[selected].action(); onClose() }
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [open, items, selected, onClose])
if (!open) return null
// 按分组聚合
const grouped: Record<string, CommandItem[]> = {}
for (const item of items) {
grouped[item.group] = grouped[item.group] || []
grouped[item.group].push(item)
}
return (
<div
className="fixed inset-0 z-[60] flex items-start justify-center bg-ink-900/50 p-4 pt-[15vh]"
onClick={onClose}
role="dialog"
aria-modal="true"
aria-label="命令面板"
>
<div
className="w-full max-w-lg border border-ink-900 bg-paper-50 shadow-2xl"
onClick={e => e.stopPropagation()}
>
{/* 搜索框 */}
<div className="flex items-center gap-2 border-b border-ink-200 px-3 py-2.5">
<span className="text-ink-400"></span>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="输入页面名或路径…"
autoFocus
className="flex-1 bg-transparent text-sm outline-none placeholder:text-ink-400"
/>
<kbd className="border border-ink-200 px-1.5 py-0.5 text-2xs text-ink-400">ESC</kbd>
</div>
{/* 结果列表 */}
<div className="max-h-[50vh] overflow-y-auto py-2">
{items.length === 0 ? (
<p className="py-6 text-center text-xs text-ink-400"></p>
) : (
Object.entries(grouped).map(([group, groupItems]) => (
<div key={group}>
<p className="px-3 py-1 text-2xs font-medium uppercase tracking-widest text-ink-400">{group}</p>
{groupItems.map((item, i) => (
<button
key={item.id}
onClick={() => { item.action(); onClose() }}
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors ${
selected === items.indexOf(item) ? 'bg-paper-100 text-press' : 'text-ink-700 hover:bg-paper-100'
}`}
>
<span className="truncate">{item.label}</span>
<span className="ml-auto text-2xs text-ink-400">{item.id}</span>
</button>
))}
</div>
))
)}
</div>
{/* 底部提示 */}
<div className="flex items-center gap-3 border-t border-ink-200 px-3 py-1.5 text-2xs text-ink-400">
<span> </span>
<span> </span>
<span>ESC </span>
</div>
</div>
</div>
)
}
+45
View File
@@ -2,6 +2,35 @@
@tailwind components;
@tailwind utilities;
/* 毛体草书(刘建毛草) - 国内 CDN,离线回退粗楷体 */
@font-face {
font-family: 'Liu Jian Mao Cao';
src: url('https://cdn.jsdelivr.net/gh/google/fonts@main/ofl/liujianmaocao/LiuJianMaoCao-Regular.ttf') format('truetype');
font-display: swap;
}
/* 粗毛笔字效果:笔触加粗 + 微描边 */
.font-brush {
font-weight: 700;
-webkit-text-stroke: 0.3px currentColor;
text-shadow: 0.5px 0.5px 0 currentColor;
}
/* 页面内容横向轻扫+淡入 */
.page-content-enter {
animation: pageSlideIn 0.3s ease-out;
}
@keyframes pageSlideIn {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@layer base {
html {
-webkit-text-size-adjust: 100%;
@@ -128,6 +157,22 @@
.skeleton {
@apply animate-pulse rounded-none bg-ink-200;
}
/* ── 侧边栏图标:统一样式 + hover过渡到品牌色 ── */
.nav-icon {
width: 1.125rem;
height: 1.125rem;
stroke-width: 1.5;
flex-shrink: 0;
opacity: 0.5;
transition: all 0.2s ease;
}
.nav-icon-wrap:hover .nav-icon,
.nav-icon-wrap.active .nav-icon {
opacity: 1;
color: #9E1B1B;
stroke-width: 2;
}
}
/* ── Admin 后台 ──
+65
View File
@@ -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)
})
+104
View File
@@ -0,0 +1,104 @@
/**
* 共享 HTTP 客户端(公开站 + Admin 统一)
*
* 特性:
* - 统一超时(默认 30s,可覆盖)
* - 统一错误处理(ApiError)
* - 401 自动广播(Admin 场景)
* - JSON/HTML 容错解析
* - 请求竞态防护(可选 signal)
*
* 注: Admin 侧的 admin/api.ts 是本模块的薄门面,不再有第二套实现。
*/
/** 会话失效事件名,AdminLayout 监听后弹出登录页 */
export const UNAUTHORIZED_EVENT = 'profeto:unauthorized'
const API_BASE = '/api/v1'
const DEFAULT_TIMEOUT = 30_000
export class ApiError extends Error {
constructor(
message: string,
public status: number,
public data?: unknown,
) {
super(message)
this.name = 'ApiError'
}
}
interface RequestOptions {
timeoutMs?: number
skipAuthHandling?: boolean
signal?: AbortSignal
method?: string
body?: string
}
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const url = path.startsWith('http') ? path : path.startsWith('/') ? path : `${API_BASE}${path}`
const { timeoutMs = DEFAULT_TIMEOUT, skipAuthHandling, signal } = options
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
// 如果外部传了 signal,也关联到内部 controller
if (signal) {
signal.addEventListener('abort', () => controller.abort(), { once: true })
}
try {
const method = (options.method ?? 'GET').toUpperCase()
const body = options.body
// 仅当有 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) {
const rawText = await res.text()
let detail: unknown = rawText
try {
detail = JSON.parse(rawText)
} catch {
// 非 JSON(如 HTML 错误页),保留原始文本
}
let message =
detail && typeof detail === 'object' && detail !== null && 'detail' in detail
? String((detail as { detail: unknown }).detail)
: `HTTP ${res.status}: ${res.statusText}`
if (res.status === 401 && !skipAuthHandling) {
message += '\n登录已过期,请重新登录。'
window.dispatchEvent(new CustomEvent(UNAUTHORIZED_EVENT))
}
throw new ApiError(message, res.status, detail)
}
if (res.status === 204) {
return undefined as T
}
return res.json() as Promise<T>
} catch (err) {
if (err instanceof ApiError) throw err
if (err instanceof DOMException && err.name === 'AbortError') {
throw new ApiError('请求超时,请稍后重试', 0)
}
throw new ApiError(
err instanceof Error ? err.message : '网络错误,请检查连接',
0,
)
} finally {
clearTimeout(timer)
}
}
export const http = {
get: <T>(path: string, opts?: RequestOptions) => request<T>(path, { ...opts }),
post: <T>(path: string, body?: unknown, opts?: RequestOptions) =>
request<T>(path, { ...opts, method: 'POST', body: body ? JSON.stringify(body) : undefined }),
put: <T>(path: string, body?: unknown, opts?: RequestOptions) =>
request<T>(path, { ...opts, method: 'PUT', body: body ? JSON.stringify(body) : undefined }),
delete: <T>(path: string, opts?: RequestOptions) => request<T>(path, { ...opts, method: 'DELETE' }),
}
File diff suppressed because it is too large Load Diff
+238
View File
@@ -0,0 +1,238 @@
/**
* 主站 - 联赛积分榜页
*
* 展示各联赛最新积分榜(位置/积分/净胜/xG差/近期走势/分区),
* 数据来自 bzzoiro /leagues/{id}/standings/ 管线采集。
*/
import { useEffect, useState, useCallback } from 'react'
import { fetchStandings } from '../admin/dal'
import type { StandingsLeague, StandingRow } from '../admin/dal'
import { useLeagues } from './matches/hooks/useLeagues'
import { Spinner } from '../admin/components'
const ZONE_META: Record<string, { label: string; cls: string }> = {
// 欧战资格
'Champions League': { label: '欧冠区', cls: 'bg-emerald-100 text-emerald-700' },
'Champions League Qualification': { label: '欧冠资格', cls: 'bg-emerald-100 text-emerald-700' },
'Europa League': { label: '欧联区', cls: 'bg-amber-100 text-amber-700' },
'Conference League': { label: '欧协杯', cls: 'bg-sky-100 text-sky-700' },
'Conference League Qualification': { label: '欧协杯', cls: 'bg-sky-100 text-sky-700' },
'Europa Conference League': { label: '欧协杯', cls: 'bg-sky-100 text-sky-700' },
'Europa Conference League Qualification': { label: '欧协杯', cls: 'bg-sky-100 text-sky-700' },
// 升级
'Championship': { label: '升级区', cls: 'bg-emerald-100 text-emerald-700' },
'Promotion': { label: '升级区', cls: 'bg-emerald-100 text-emerald-700' },
'Promotion Group': { label: '升级组', cls: 'bg-emerald-100 text-emerald-700' },
// 降级
'Relegation': { label: '降级区', cls: 'bg-rose-100 text-rose-700' },
'Relegation Playoffs': { label: '降级附加赛', cls: 'bg-orange-100 text-orange-700' },
'Relegation Group': { label: '降级组', cls: 'bg-rose-100 text-rose-700' },
// 附加赛
'Playoffs': { label: '附加赛', cls: 'bg-amber-100 text-amber-700' },
'Championship Playoffs': { label: '升级附加赛', cls: 'bg-amber-100 text-amber-700' },
'Qualification Playoffs': { label: '资格附加赛', cls: 'bg-sky-100 text-sky-700' },
'Qualification': { label: '资格赛', cls: 'bg-sky-100 text-sky-700' },
}
function zoneBadge(zone?: string | null) {
if (!zone) return null
const meta = ZONE_META[zone] ?? { label: zone, cls: 'bg-ink-100 text-ink-600' }
return <span className={`whitespace-nowrap rounded px-1.5 py-0.5 text-2xs font-medium ${meta.cls}`}>{meta.label}</span>
}
/** 近期走势串(W/D/L) → 彩色圆点 */
function FormDots({ form }: { form?: string | null }) {
if (!form) return <span className="text-2xs text-ink-400"></span>
const colorMap: Record<string, string> = { W: 'bg-emerald-500', D: 'bg-ink-300', L: 'bg-rose-500' }
return (
<span className="inline-flex gap-0.5">
{form.slice(0, 5).split('').map((c, i) => (
<span key={i} className={`inline-block h-1.5 w-1.5 rounded-full ${colorMap[c] ?? 'bg-ink-200'}`} />
))}
</span>
)
}
export default function StandingsPage() {
// 统一数据源:复用 useLeagues hook(优先 API,失败回退本地常量)
const leagues = useLeagues()
const [standings, setStandings] = useState<StandingsLeague[]>([])
const [activeLeague, setActiveLeague] = useState<string>('')
const [loading, setLoading] = useState(true)
const [switching, setSwitching] = useState(false) // 切换联赛中
const [error, setError] = useState<string | null>(null)
const [showBackTop, setShowBackTop] = useState(false) // 回到顶部按钮显示态
// 监听滚动,超过 300px 显示回到顶部按钮
useEffect(() => {
const handleScroll = () => setShowBackTop(window.scrollY > 300)
window.addEventListener('scroll', handleScroll, { passive: true })
return () => window.removeEventListener('scroll', handleScroll)
}, [])
const scrollToTop = () => window.scrollTo({ top: 0, behavior: 'smooth' })
const load = useCallback(async (code?: string) => {
setLoading(true)
setError(null)
try {
const data = await fetchStandings(code)
setStandings(data.leagues)
if (!activeLeague && data.leagues.length > 0) {
setActiveLeague(data.leagues[0].league_code)
}
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败')
} finally {
setLoading(false)
}
}, [activeLeague])
useEffect(() => { load() }, []) // eslint-disable-line react-hooks/exhaustive-deps
// 切换联赛(带加载态,禁用 tab 防重复点击)
const switchLeague = async (code: string) => {
if (code === activeLeague || switching) return
setSwitching(true)
setActiveLeague(code)
try {
await fetchStandings(code).then(data => setStandings(data.leagues))
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败')
} finally {
setSwitching(false)
}
}
const active = standings.find(l => l.league_code === activeLeague) ?? standings[0]
return (
<div className="space-y-6">
{/* 联赛切换 */}
<div className="flex flex-wrap gap-2">
{leagues.map(l => {
// 标记该联赛是否有积分榜数据:有数据可正常切换,无数据也可选中但显示空态
const hasData = standings.some(s => s.league_code === l.code)
const isEmpty = activeLeague === l.code && !hasData
return (
<button
key={l.code}
onClick={() => switchLeague(l.code)}
disabled={switching}
title={hasData ? undefined : '暂无积分榜数据'}
className={`rounded border px-3 py-1.5 text-xs transition-colors disabled:opacity-50 ${
activeLeague === l.code
? 'border-ink-900 bg-ink-900 text-paper-50'
: 'border-ink-200 text-ink-500 hover:border-ink-300'
} ${!hasData ? 'border-dashed' : ''}`}
>
{l.name}
</button>
)
})}
</div>
{error && (
<div className="border border-rose-300 bg-rose-50 px-4 py-3 text-sm text-rose-700">
{error}
</div>
)}
{loading && (
<div className="flex justify-center py-12 text-xs text-ink-400"></div>
)}
{/* 切换联赛时的轻量加载指示 */}
{switching && !loading && (
<div className="flex items-center gap-2 border-b border-ink-200 px-1 py-2 text-xs text-ink-400">
<Spinner />
</div>
)}
{!loading && !active && (
<div className="border-y border-ink-200 py-12 text-center">
<p className="font-serif text-sm text-ink-600"></p>
<p className="mt-1.5 text-xs text-ink-400">
</p>
</div>
)}
{active && (
<div>
<div className="mb-3 flex items-center justify-between border-b border-ink-200 pb-2">
<div>
<h2 className="font-serif text-lg font-bold text-ink-900">
{active.league_name}
</h2>
<p className="text-xs text-ink-400">
{active.season} · {active.rows.length}
{active.retrieved_at && ` · 更新于 ${new Date(active.retrieved_at).toLocaleDateString('zh-CN')}`}
</p>
</div>
</div>
{/* 积分榜表格 */}
<div className="overflow-x-auto">
<table className="w-full min-w-[640px] text-xs">
<thead>
<tr className="border-b border-ink-200 text-left text-ink-400">
<th className="w-8 py-2 font-medium">#</th>
<th className="py-2 font-medium"></th>
<th className="w-10 text-center py-2 font-medium"></th>
<th className="w-10 text-center py-2 font-medium"></th>
<th className="w-10 text-center py-2 font-medium"></th>
<th className="w-10 text-center py-2 font-medium"></th>
<th className="w-12 text-center py-2 font-medium">/</th>
<th className="w-12 text-center py-2 font-medium"></th>
<th className="w-14 text-center py-2 font-medium"></th>
<th className="w-16 text-center py-2 font-medium">xG±</th>
<th className="w-20 text-center py-2 font-medium"></th>
<th className="w-16 text-right py-2 font-medium"></th>
</tr>
</thead>
<tbody>
{active.rows.map((r: StandingRow) => (
<tr key={r.position} className="border-b border-ink-100 hover:bg-paper-100">
<td className="py-2 font-medium text-ink-700">{r.position}</td>
<td className="py-2 font-medium text-ink-900">{r.team}</td>
<td className="text-center py-2 text-ink-500">{r.played}</td>
<td className="text-center py-2 text-ink-500">{r.won}</td>
<td className="text-center py-2 text-ink-500">{r.drawn}</td>
<td className="text-center py-2 text-ink-500">{r.lost}</td>
<td className="text-center py-2 text-ink-500">{r.goals_for}/{r.goals_against}</td>
<td className={`text-center py-2 ${r.goal_diff > 0 ? 'text-emerald-600' : r.goal_diff < 0 ? 'text-rose-600' : 'text-ink-500'}`}>
{r.goal_diff > 0 ? `+${r.goal_diff}` : r.goal_diff}
</td>
<td className="text-center py-2 font-bold text-ink-900">{r.points}</td>
<td className="text-center py-2 text-ink-500">
{r.xg_for != null && r.xg_against != null
? `${(r.xg_for - r.xg_against).toFixed(1)}`
: '—'}
</td>
<td className="py-2"><div className="flex justify-center"><FormDots form={r.form} /></div></td>
<td className="py-2 text-right">{zoneBadge(r.zone)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* 回到顶部按钮 */}
<button
onClick={scrollToTop}
className={`fixed bottom-6 right-6 z-40 flex h-10 w-10 items-center justify-center rounded-full border border-ink-200 bg-paper-50 text-ink-600 shadow-lg transition-all duration-300 hover:border-ink-400 hover:text-ink-900 ${
showBackTop ? 'translate-y-0 opacity-100' : 'translate-y-4 opacity-0 pointer-events-none'
}`}
aria-label="回到顶部"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
</svg>
</button>
</div>
)
}
@@ -0,0 +1,171 @@
/**
* AgentsPanel: 五路专家意见 —— 可折叠 + 状态摘要 + 权重条形图 + 单路详情。
*
* P3-1: 从 MatchPredictPanel.PredictionPanel 拆出,渲染逻辑原样搬迁。
*/
import { useState } from 'react'
import type { AgentReport, Prediction } from '../../types'
import { AGENT_LABELS, CN_NUM } from '../../types'
const STATUS_BADGE: Record<string, { label: string; cls: string }> = {
ok: { label: '正常', cls: 'text-ink-500' },
no_data: { label: '无数据', cls: 'text-ink-400' },
error: { label: '调用失败', cls: 'text-press' },
parse_error: { label: '解析失败', cls: 'text-press' },
}
const SUFFICIENCY_LABEL: Record<string, string> = {
high: '充分',
medium: '一般',
low: '偏少',
none: '无',
}
/** home_edge(-1~1,正=利主队)的可视化:以中线为原点的双向细条 */
function EdgeBar({ value }: { value: number }) {
const v = Math.max(-1, Math.min(1, value))
const half = Math.abs(v) * 50
return (
<div className="relative h-px w-full bg-ink-200" role="presentation">
<span className="absolute left-1/2 top-1/2 h-2 w-px -translate-x-1/2 -translate-y-1/2 bg-ink-400" />
<span
className={`absolute top-0 h-px transition-all duration-500 ${v >= 0 ? 'bg-press' : 'bg-ink-600'}`}
style={
v >= 0
? { left: '50%', width: `${half}%` }
: { right: '50%', width: `${half}%` }
}
/>
</div>
)
}
/** 单路专家意见:汉字编号 + 细线行 */
function AgentCard({ report: r, no }: { report: AgentReport; no: string }) {
const badge = STATUS_BADGE[r.status] ?? { label: r.status, cls: 'text-ink-400' }
const inactive = r.status !== 'ok'
return (
<details className="group border-b border-ink-200">
<summary className="flex cursor-pointer list-none items-baseline gap-2.5 px-1 py-3">
<span className="font-serif text-sm text-ink-400">{no}</span>
<span className="text-sm font-medium text-ink-900">{AGENT_LABELS[r.agent] ?? r.agent}</span>
<span className={`text-2xs ${badge.cls}`}>{badge.label}</span>
<span className="ml-auto flex items-baseline gap-3 text-2xs tabular-nums text-ink-500">
{r.status === 'ok' && r.subjective_confidence !== null && (
<span> {Math.round(r.subjective_confidence * 100)}%</span>
)}
{r.status === 'ok' && r.probable_score && (
<span className="font-serif font-bold text-ink-800">{r.probable_score}</span>
)}
<svg viewBox="0 0 20 20" className="h-3 w-3 self-center text-ink-300 transition-transform group-open:rotate-90" fill="currentColor" aria-hidden="true">
<path d="M7.3 5.3a1 1 0 011.4 0l4 4a1 1 0 010 1.4l-4 4a1 1 0 01-1.4-1.4L10.6 10 7.3 6.7a1 1 0 010-1.4z" />
</svg>
</span>
</summary>
<div className="space-y-3 px-1 pb-4 pl-7">
{inactive && (
<p className="text-xs leading-relaxed text-ink-500">
{r.status === 'no_data' && '该维度没有可用数据,已跳过 LLM 分析以节省额度(不影响其他专家)。'}
{r.status === 'error' && '该专家调用失败,本次结论未纳入其视角(fail-open 设计,不阻断整体预测)。'}
{r.status === 'parse_error' && '模型输出未通过格式校验,该报告已丢弃。'}
</p>
)}
{!inactive && r.home_edge !== null && (
<div>
<div className="mb-1.5 flex items-baseline justify-between text-2xs">
<span className="text-ink-500"></span>
<span className={`font-semibold tabular-nums ${r.home_edge > 0 ? 'text-press' : r.home_edge < 0 ? 'text-ink-700' : 'text-ink-500'}`}>
{r.home_edge > 0 ? '+' : ''}{r.home_edge.toFixed(2)}
</span>
</div>
<EdgeBar value={r.home_edge} />
<div className="mt-1 flex justify-between text-2xs text-ink-400">
<span></span>
<span></span>
</div>
</div>
)}
{r.analysis && (
<p className="font-serif text-sm leading-loose text-ink-700">{r.analysis}</p>
)}
{r.key_evidence.length > 0 && (
<ul className="space-y-1.5">
{r.key_evidence.map((e, i) => (
<li key={i} className="flex gap-2 text-xs leading-relaxed text-ink-600">
<span className="flex-shrink-0 text-ink-300" aria-hidden="true"></span>
<span>{e}</span>
</li>
))}
</ul>
)}
{r.exp_home_goals !== null && r.exp_away_goals !== null && (
<p className="text-xs text-ink-500">
<span className="font-serif font-bold tabular-nums text-ink-900">{r.exp_home_goals.toFixed(1)} - {r.exp_away_goals.toFixed(1)}</span>
</p>
)}
{!inactive && (
<p className="border-t border-ink-100 pt-2.5 text-2xs text-ink-400">
{SUFFICIENCY_LABEL[r.data_sufficiency] ?? r.data_sufficiency}
<span className="mx-2 text-ink-200">|</span>
<span className="font-mono">{r.model}</span>
{r.latency_ms !== null && <span className="ml-2 tabular-nums">{r.latency_ms}ms</span>}
</p>
)}
</div>
</details>
)
}
export function AgentsPanel({ prediction }: { prediction: Prediction }) {
const [expertsOpen, setExpertsOpen] = useState(false)
const degraded = prediction.status === 'degraded' || prediction.status === 'failed'
const reports = prediction.agent_outputs ?? []
const okReports = reports.filter(r => r.status === 'ok')
if (reports.length === 0) return null
return (
<section>
<button
onClick={() => setExpertsOpen(o => !o)}
className="flex w-full items-center justify-between border-b border-ink-200 pb-2 text-left"
>
<span className="section-head mb-0">({okReports.length}/{reports.length} )</span>
<span className="text-2xs text-ink-400">{expertsOpen ? '收起' : '展开'}</span>
</button>
{!degraded && prediction.agent_weights && Object.keys(prediction.agent_weights).length > 0 && (
<div className="mt-3 space-y-1.5">
<span className="text-2xs text-ink-500"></span>
{Object.entries(prediction.agent_weights)
.sort((a, b) => b[1] - a[1])
.map(([k, v]) => (
<div key={k} className="grid grid-cols-[96px_minmax(0,1fr)_40px] items-center gap-2">
<span className="truncate text-2xs text-ink-500">{AGENT_LABELS[k] ?? k}</span>
<div className="h-1.5 bg-paper-100">
<div className="h-full bg-press" style={{ width: `${Math.round(v * 100)}%` }} />
</div>
<span className="text-right text-2xs tabular-nums text-ink-500">{Math.round(v * 100)}%</span>
</div>
))}
</div>
)}
{expertsOpen && (
<div className="mt-2">
{reports.map((r, i) => (
<AgentCard key={r.agent} report={r} no={CN_NUM[i] ?? String(i + 1)} />
))}
</div>
)}
</section>
)
}
@@ -0,0 +1,369 @@
/**
* MatchDetailSection: 赛程行(MatchRow)+ 展开详情面板(统计/近况/H2H/历史预测)。
*
* D3: 从 Matches.tsx 拆出,渲染逻辑原样搬迁。MatchRow 原先是主组件内
* group.map 的内联 JSX,现接收回调(onToggle/onPredict)保持行为一致;
* 展开懒加载的 state 仍由页面持有。
*/
import TeamSideTag from '../../../components/TeamSideTag'
import { fetchMatchDetail, fetchMatchContext } from '../../../admin/dal'
import type { MatchDetailOut, MatchContextOut, MatchRecentPrediction, TeamRecentMatch } from '../../../admin/types'
import type { MatchStatsDetail } from '../../../admin/types'
import { STATUS_META } from '../types'
import type { Match } from '../types'
import { Spinner } from '../ui'
/** 比赛详情面板:双方近况/H2H + 历史预测列表(只读) */
function MatchDetailPanel({
match, detail, ctx, loading,
}: {
match: Match
detail: MatchDetailOut | undefined
ctx: MatchContextOut | undefined
loading: boolean
}) {
const homeName = match.home_team_zh || match.home_team
const awayName = match.away_team_zh || match.away_team
const finished = match.match_status === 'finished'
return (
<div className="border-b border-ink-200 bg-paper-100/50 px-3 py-4">
{loading && (
<div className="flex items-center gap-2 text-xs text-ink-500"><Spinner /> </div>
)}
{!loading && !detail && !ctx && (
<p className="py-4 text-center text-xs text-ink-400"></p>
)}
{!loading && (detail || ctx) && (
<div className="space-y-5">
{/* 比分区(终场/当前比分 + 状态 + 预测按钮) */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="text-center">
<p className="font-serif text-3xl font-bold tabular-nums leading-none text-ink-900">
{match.home_goals ?? '-'}{' '}<span className="text-ink-300">:</span>{' '}{match.away_goals ?? '-'}
</p>
<p className="mt-1 text-2xs text-ink-500">
{match.match_stage || ''} {match.match_status === 'finished' ? '· 已完赛' : match.match_status === 'scheduled' ? '· 未开赛' : `· ${match.match_status}`}
</p>
{match.home_xg != null && match.away_xg != null && (
<p className="text-2xs tabular-nums text-ink-400">xG {match.home_xg.toFixed(1)}{match.away_xg.toFixed(1)}</p>
)}
</div>
{!finished && (
<span className="text-2xs text-ink-500">
</span>
)}
</div>
{/* 比赛详细统计(bzzoiro /events/{id}/stats/) */}
{detail?.stats && (
<MatchStatsPanel stats={detail.stats} homeName={homeName} awayName={awayName} />
)}
{/* 双方近况 + H2H */}
{(ctx?.home_recent?.length || ctx?.away_recent?.length || ctx?.h2h?.length) ? (
<div className="grid gap-4 sm:grid-cols-3">
<RecentBlock title={`${homeName} 近况`} rows={ctx?.home_recent} side="home" />
<RecentBlock title={`${awayName} 近况`} rows={ctx?.away_recent} side="away" />
<RecentBlock title="历史交锋(H2H)" rows={ctx?.h2h} side="h2h" />
</div>
) : (
!loading && <p className="text-2xs text-ink-400"></p>
)}
{/* 历史预测列表 */}
<div>
<h4 className="section-head mb-2">({detail?.recent_predictions?.length ?? 0})</h4>
{detail?.recent_predictions?.length ? (
<div className="space-y-2">
{detail.recent_predictions.map(p => (
<PredictionHistoryRow key={p.id} p={p} />
))}
</div>
) : (
<p className="py-3 text-center text-2xs text-ink-400"></p>
)}
</div>
</div>
)}
</div>
)
}
/** 赛程表一行:可点击展开详情;展开时懒加载详情(只读,不触发 LLM) */
export function MatchRow({
m,
busy,
expanded,
detail,
ctx,
detailLoading,
onToggle,
onPredict,
}: {
m: Match
busy: boolean
expanded: boolean
detail: MatchDetailOut | undefined
ctx: MatchContextOut | undefined
detailLoading: boolean
onToggle: () => void
onPredict: (m: Match) => void
}) {
const st = STATUS_META[m.match_status] ?? { label: m.match_status, cls: 'text-ink-400' }
const homeName = m.home_team_zh || m.home_team
const awayName = m.away_team_zh || m.away_team
const finished = m.match_status === 'finished'
return (
<div>
{/* 行:可点击展开 */}
<div
className={`border-b border-ink-200 px-4 py-5 transition-colors hover:bg-paper-100/70 cursor-pointer sm:px-1 sm:py-4 ${
expanded ? 'bg-paper-100/60' : ''
}`}
onClick={onToggle}
role="button"
tabIndex={0}
onKeyDown={e => { if (e.key === 'Enter') onToggle() }}
aria-expanded={expanded}
>
{/* 桌面 grid: 日期 | 主队 | 比分 | 客队 | 状态 | 按钮 */}
<div className="flex flex-col gap-3 sm:grid sm:grid-cols-[96px_minmax(0,1fr)_72px_minmax(0,1fr)_64px_88px] sm:items-center sm:gap-x-4 sm:gap-y-0">
{/* 日期 + 状态:小屏同行;桌面 date 单独一列 */}
<div className="flex items-center justify-between text-xs sm:contents">
<span className="tabular-nums text-ink-500 sm:text-xs">{fmtTime(m.match_date)}</span>
<span className={`sm:hidden ${st.cls}`}>{st.label}</span>
</div>
{/* 主队 + 比分 + 客队:移动端 grid 三列(严格居中),桌面端 grid 分列 */}
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-4 sm:contents">
{/* 主队(右对齐) */}
<span className="flex min-w-0 items-center justify-end gap-2">
<TeamSideTag side="home" />
<span className="truncate text-sm font-medium text-ink-900">{homeName}</span>
</span>
{/* 比分 / VS(严格居中) */}
<span className="flex flex-col items-center justify-center">
{m.home_goals !== null && m.away_goals !== null ? (
<span className="font-serif text-xl font-bold tabular-nums leading-none text-ink-900 sm:text-xl">
{m.home_goals}<span className="mx-1 font-normal text-ink-300">:</span>{m.away_goals}
</span>
) : (
<span className="text-sm tracking-[0.2em] text-ink-500">VS</span>
)}
{m.home_xg !== null && m.away_xg !== null && (
<span className="mt-0.5 text-2xs tabular-nums text-ink-400">
xG {m.home_xg.toFixed(1)}{m.away_xg.toFixed(1)}
</span>
)}
</span>
{/* 客队(左对齐) */}
<span className="flex min-w-0 items-center gap-2">
<TeamSideTag side="away" />
<span className="truncate text-sm font-medium text-ink-900">{awayName}</span>
</span>
</div>
{/* 状态标签:小屏隐藏(已有);桌面用徽标样式 */}
<span className="hidden text-right sm:block">
<span className={`inline-block border px-1.5 py-0.5 text-2xs leading-tight ${st.cls} ${
m.match_status === 'finished'
? 'border-ink-200 text-ink-500'
: m.match_status === 'scheduled'
? 'border-ink-300 text-ink-600'
: 'border-press/30 text-press'
}`}>
{st.label}
</span>
</span>
{/* 预测按钮(统一,响应式尺寸) */}
{!finished && (
<div className="flex justify-end" onClick={e => e.stopPropagation()}>
<button
onClick={() => onPredict(m)}
disabled={busy}
className={`btn ${busy ? '' : 'btn-solid'} w-full min-h-[44px] sm:w-[84px] sm:min-h-0 sm:btn-sm`}
title="以多专家模式预测这场"
>
{busy ? (<><Spinner /> </>) : '预测'}
</button>
</div>
)}
</div>
</div>{/* 关闭可点击行 */}
{/* 展开详情面板 */}
{expanded && (
<MatchDetailPanel
match={m} detail={detail} ctx={ctx}
loading={detailLoading}
/>
)}
</div>
)
}
/** 行内时间展示:只显示 HH:mm(日期由分组头承担) */
function fmtTime(s: string): string {
const d = new Date(s)
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })
}
/** 比赛详细统计面板(bzzoiro /events/{id}/stats/) */
function MatchStatsPanel({
stats, homeName, awayName,
}: { stats: MatchStatsDetail; homeName: string; awayName: string }) {
const rows: Array<{ label: string; home: number | null; away: number | null; highlight?: 'high' | 'low' }> = [
{ label: '预期进球(xG)', home: stats.home_xg, away: stats.away_xg },
{ label: '射门', home: stats.home_shots, away: stats.away_shots },
{ label: '射正', home: stats.home_shots_on_target, away: stats.away_shots_on_target },
{ label: '角球', home: stats.home_corners, away: stats.away_corners },
{ label: '犯规', home: stats.home_fouls, away: stats.away_fouls },
{ label: '绝佳机会', home: stats.home_big_chances, away: stats.away_big_chances },
{ label: '黄牌', home: stats.home_yellow_cards, away: stats.away_yellow_cards },
{ label: '红牌', home: stats.home_red_cards, away: stats.away_red_cards },
]
const hasAny = rows.some(r => r.home != null || r.away != null)
if (!hasAny) return null
// 控球率用横条展示
const possHome = stats.home_possession
const possAway = possHome != null ? Math.max(0, 100 - possHome) : null
return (
<div>
<h4 className="section-head mb-2"></h4>
{/* 控球率横条 */}
{possHome != null && possAway != null && (
<div className="mb-3">
<div className="mb-1 flex justify-between text-2xs text-ink-500">
<span>{possHome.toFixed(0)}%</span>
<span className="text-ink-400"></span>
<span>{possAway.toFixed(0)}%</span>
</div>
<div className="flex h-1.5 overflow-hidden rounded-full bg-ink-200">
<div className="bg-ink-700 transition-[width] duration-500" style={{ width: `${possHome}%` }} />
<div className="bg-ink-300 transition-[width] duration-500" style={{ width: `${possAway}%` }} />
</div>
</div>
)}
{/* 主客对比表 */}
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-ink-200 text-ink-400">
<th className="py-1.5 text-left font-medium">{homeName}</th>
<th className="py-1.5 text-center font-medium text-ink-500"></th>
<th className="py-1.5 text-right font-medium">{awayName}</th>
</tr>
</thead>
<tbody>
{rows.filter(r => r.home != null || r.away != null).map(r => {
const h = r.home ?? 0
const a = r.away ?? 0
const winner = h > a ? 'home' : h < a ? 'away' : 'tie'
return (
<tr key={r.label} className="border-b border-ink-100">
<td className={`py-1.5 text-right tabular-nums ${winner === 'home' ? 'font-bold text-ink-900' : 'text-ink-500'}`}>
{r.home ?? '—'}
</td>
<td className="py-1.5 text-center text-ink-500">{r.label}</td>
<td className={`py-1.5 text-left tabular-nums ${winner === 'away' ? 'font-bold text-ink-900' : 'text-ink-500'}`}>
{r.away ?? '—'}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)
}
/** 近况/H2H 单区块 */
function RecentBlock({ title, rows, side }: { title: string; rows?: TeamRecentMatch[]; side: 'home' | 'away' | 'h2h' }) {
return (
<div>
<h5 className="mb-1.5 text-2xs font-medium text-ink-500">{title}</h5>
{rows && rows.length > 0 ? (
<ul className="space-y-1">
{rows.map((r, i) => {
const date = r.match_date ? new Date(r.match_date).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' }) : '—'
const score = (r.home_goals != null && r.away_goals != null) ? `${r.home_goals}-${r.away_goals}` : 'vs'
const label = side === 'h2h'
? `${r.home_team ?? '?'} ${score} ${r.away_team ?? '?'}`
: `${score}`
return (
<li key={i} className="flex items-center justify-between text-2xs tabular-nums text-ink-600">
<span className="text-ink-400">{date}</span>
<span className="truncate">{label}</span>
</li>
)
})}
</ul>
) : (
<p className="text-2xs text-ink-300"></p>
)}
</div>
)
}
/** 历史预测单行(含专家报告入口) */
function PredictionHistoryRow({ p }: { p: MatchRecentPrediction }) {
const badge = p.status === 'degraded'
? { label: 'degraded', cls: 'text-press' }
: p.settled
? { label: p.correct_1x2 === undefined ? '已结算' : p.correct_1x2 ? '命中' : '未中', cls: p.correct_1x2 ? 'text-ink-900' : 'text-ink-400' }
: { label: p.status === 'success' ? '成功' : p.status, cls: 'text-ink-600' }
const score = (p.pred_home_goals != null && p.pred_away_goals != null)
? `${p.pred_home_goals.toFixed(1)}-${p.pred_away_goals.toFixed(1)}`
: '—'
const alt = (p.alt_pred_home_goals != null && p.alt_pred_away_goals != null)
? `${p.alt_pred_home_goals.toFixed(1)}-${p.alt_pred_away_goals.toFixed(1)}` : null
const hasAgents = p.agent_outputs && p.agent_outputs.length > 0
return (
<div className="border-b border-ink-200 pb-2 last:border-b-0">
<div className="flex items-center justify-between text-xs">
<span className="tabular-nums text-ink-600">
{score} {p.pred_1x2 ? `(${p.pred_1x2})` : ''}
{alt && <span className="ml-1 text-ink-400"> {alt}</span>}
</span>
<span className="flex items-center gap-2">
{p.subjective_confidence != null && (
<span className="text-2xs tabular-nums text-ink-400"> {Math.round(p.subjective_confidence * 100)}%</span>
)}
<span className={`text-2xs ${badge.cls}`}>{badge.label}</span>
</span>
</div>
<div className="mt-0.5 flex items-center justify-between text-2xs text-ink-400">
<span className="truncate">{p.model} · {p.mode} · {p.created_at ? new Date(p.created_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }) : '—'}</span>
{hasAgents && <span className="text-press">{p.agent_outputs!.length} </span>}
</div>
{p.reasoning && (
<p className="mt-1 line-clamp-2 font-serif text-2xs leading-relaxed text-ink-500">{p.reasoning}</p>
)}
</div>
)
}
// 详情懒加载取数在此文件内聚:页面只需切换 expandedId 并缓存结果
export async function loadMatchDetailBundle(
matchId: number,
): Promise<{ detail: MatchDetailOut | null; ctx: MatchContextOut | null }> {
const [d, c] = await Promise.all([
fetchMatchDetail(matchId).catch(() => null),
fetchMatchContext(matchId).catch(() => null),
])
return { detail: d, ctx: c }
}
@@ -0,0 +1,247 @@
/**
* MatchPredictPanel: 预测弹窗全套(过程可视化 / 结果版面 / 专家意见)。
*
* D3: 从 Matches.tsx 拆出,渲染逻辑原样搬迁。对外只导出 PredictModal;
* PredictionPanel 复用 Prediction 的 embedded 模式由弹窗内渲染。
*/
import { useEffect, useState } from 'react'
import TeamSideTag from '../../../components/TeamSideTag'
import type { Match, Prediction } from '../types'
import { AGENT_LABELS } from '../types'
import { AgentsPanel } from './AgentsPanel'
import { OutcomePanel } from './OutcomePanel'
import { ReasoningPanel } from './ReasoningPanel'
function Spinner({ className = '' }: { className?: string }) {
return (
<svg
viewBox="0 0 20 20"
className={`h-3.5 w-3.5 animate-spin ${className}`}
fill="none"
aria-hidden="true"
>
<circle cx="10" cy="10" r="7.5" stroke="currentColor" strokeWidth="1.5" strokeOpacity="0.25" />
<path d="M17.5 10A7.5 7.5 0 0010 2.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)
}
/** 胜平负一行文字:选中的红字加方块标记,未选中的退灰 */
.**
* P3-1:PredictionPanel ,:
* OutcomePanel(//) / AgentsPanel() / ReasoningPanel(/)
* ( + + )
*/
function PredictionPanel({
prediction,
match,
embedded = false,
}: {
prediction: Prediction
match: Match
/** 弹窗嵌入模式:弹窗已提供报头,这里省略自带版头 */
embedded?: boolean
}) {
const homeName = match.home_team_zh || match.home_team
const awayName = match.away_team_zh || match.away_team
const degraded = prediction.status === 'degraded' || prediction.status === 'failed'
const reports = prediction.agent_outputs ?? []
const okReports = reports.filter(r => r.status === 'ok')
return (
<article className={embedded ? 'bg-paper-50' : 'border border-ink-900 bg-paper-50'}>
{!embedded && (
<div className="flex flex-wrap items-baseline justify-between gap-2 border-b border-ink-900 bg-paper-100 px-4 py-2.5 sm:px-5">
<h3 className="flex flex-wrap items-center gap-1.5 font-serif text-sm font-bold text-ink-900">
·
<TeamSideTag side="home" />
{homeName}
<span></span>
<TeamSideTag side="away" />
{awayName}
</h3>
<span className="text-2xs tabular-nums text-ink-500">
{prediction.provider} / {prediction.model}
{prediction.latency_ms !== null && ` · ${(prediction.latency_ms / 1000).toFixed(1)}s`}
</span>
</div>
)}
<div className="space-y-7 px-4 py-6 sm:px-5">
{degraded && (
<div className="border-l-2 border-press bg-press-wash/40 px-4 py-3">
<p className="font-serif text-sm font-bold text-press-dark">
{prediction.status === 'failed' ? '预测失败' : '预测降级(degraded)'}
</p>
<p className="mt-1.5 whitespace-pre-wrap text-xs leading-relaxed text-ink-600">
{prediction.reasoning || '所有专家均无有效数据或调用失败,无法生成可靠比分。'}
</p>
</div>
)}
{!degraded && <OutcomePanel prediction={prediction} match={match} />}
<p className="text-center text-2xs text-ink-500">
`多专家模式 · ${okReports.length}/${reports.length} 路有效`
{prediction.prompt_version && ` · prompt ${prediction.prompt_version}`}
</p>
<AgentsPanel prediction={prediction} />
<ReasoningPanel prediction={prediction} />
</div>
</article>
)
}
function PredictProgress() {
const [elapsed, setElapsed] = useState(0)
useEffect(() => {
const t = setInterval(() => setElapsed(e => e + 0.5), 500)
return () => clearInterval(t)
}, [])
// 阶段阈值(秒): 切片 → 专家(各路依次点亮) → 终裁
const SLICE_END = 3
const AGENT_START = 4
const AGENT_STEP = 8 // 每路专家约 8s 点亮一路
const AGG_START = AGENT_START + AGENT_STEP * 5
const agents = ['form', 'stats', 'home_away', 'standings', 'h2h']
const phase = elapsed < SLICE_END ? 'slice'
: elapsed < AGG_START ? 'agents' : 'agg'
const pct = Math.min(95, Math.round((elapsed / 70) * 100))
return (
<div className="px-5 py-8 sm:px-8">
{/* 阶段标题 */}
<div className="flex items-center justify-center gap-2">
<Spinner className="text-press" />
<span className="font-serif text-sm font-bold text-ink-900">
{phase === 'slice' && '正在组装比赛数据切片'}
{phase === 'agents' && '五路专家并行分析中'}
{phase === 'agg' && '终裁专家汇总裁定中'}
</span>
<span className="text-2xs tabular-nums text-ink-400">{elapsed.toFixed(0)}s</span>
</div>
{/* 进度条:渐进式,不封顶到 100% */}
<div className="mx-auto mt-5 h-1 w-full max-w-md overflow-hidden bg-ink-100" role="progressbar" aria-valuenow={pct}>
<div
className={`h-full bg-press transition-all duration-500 ${phase === 'agg' ? 'animate-pulse' : ''}`}
style={{ width: `${pct}%` }}
/>
</div>
{/* 专家灯序(多专家模式) */}
<ul className="mx-auto mt-6 max-w-md space-y-1.5">
{agents.map((a, i) => {
const lit = elapsed >= AGENT_START + AGENT_STEP * (i + 1)
const activeNow = !lit && elapsed >= AGENT_START + AGENT_STEP * i
return (
<li
key={a}
className={`flex items-center justify-between border-b border-ink-200 pb-1.5 text-xs transition-colors ${
lit ? 'text-ink-800' : activeNow ? 'text-ink-900' : 'text-ink-300'
}`}
>
<span className="flex items-center gap-2">
<span
aria-hidden="true"
className={`inline-block h-1.5 w-1.5 ${lit ? 'bg-ink-900' : activeNow ? 'bg-press animate-pulse' : 'bg-ink-200'}`}
/>
{AGENT_LABELS[a] ?? a}
</span>
{lit && <span className="text-2xs text-ink-400"> </span>}
{activeNow && <span className="text-2xs text-press"></span>}
</li>
)
})}
</ul>
<p className="mt-6 text-center text-2xs text-ink-400">
, 30-90 ; token,使
</p>
<p className="mt-1 text-center text-2xs text-ink-300">
提示:每分钟限 10 ,
</p>
</div>
)
}
export function PredictModal({
match,
predicting,
prediction,
error,
onClose,
}: {
match: Match
predicting: boolean
prediction: Prediction | null
error: string | null
onClose: () => void
}) {
const homeName = match.home_team_zh || match.home_team
const awayName = match.away_team_zh || match.away_team
useEffect(() => {
const h = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', h)
return () => document.removeEventListener('keydown', h)
}, [onClose])
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-ink-900/50 p-4 sm:items-center"
role="dialog"
aria-modal="true"
aria-label={`预测 ${homeName}${awayName}`}
onClick={e => {
if (e.target === e.currentTarget) onClose()
}}
>
<div className="relative flex max-h-[92vh] w-full max-w-2xl flex-col overflow-hidden bg-paper-50 shadow-2xl">
{/* 弹窗报头 */}
<div className="flex flex-shrink-0 items-center justify-between border-b border-ink-900 bg-paper-100 px-4 py-2.5 sm:px-5">
<h3 className="flex flex-wrap items-center gap-1.5 font-serif text-sm font-bold text-ink-900">
·
<TeamSideTag side="home" />
{homeName}
<span></span>
<TeamSideTag side="away" />
{awayName}
</h3>
<button
onClick={onClose}
className="flex h-11 w-11 flex-shrink-0 items-center justify-center text-ink-400 transition-colors hover:text-ink-900"
aria-label="关闭"
>
<svg className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M6.3 5.3a1 1 0 011.4 0L10 7.6l2.3-2.3a1 1 0 111.4 1.4L11.4 9l2.3 2.3a1 1 0 01-1.4 1.4L10 10.4l-2.3 2.3a1 1 0 01-1.4-1.4L8.6 9 6.3 6.7a1 1 0 010-1.4z" />
</svg>
</button>
</div>
{/* 弹窗体(小屏可滚动) */}
<div className="flex-1 overflow-y-auto">
{predicting ? (
<PredictProgress />
) : error ? (
<div className="px-5 py-10 text-center sm:px-8">
<p className="font-serif text-sm font-bold text-press"></p>
<p className="mx-auto mt-3 max-w-md whitespace-pre-wrap text-left text-xs leading-relaxed text-ink-600">
{error}
</p>
<button onClick={onClose} className="btn btn-sm mt-6"></button>
</div>
) : prediction ? (
<PredictionPanel prediction={prediction} match={match} embedded />
) : null}
</div>
</div>
</div>
)
}
@@ -0,0 +1,126 @@
/**
* OutcomePanel: 预测主结论 —— 比分 / 胜平负 / 置信度 / 成本。
*
* P3-1: 从 MatchPredictPanel.PredictionPanel 拆出,渲染逻辑原样搬迁。
*/
import TeamSideTag from '../../../../components/TeamSideTag'
import type { Match, Prediction } from '../../types'
import { OUTCOME_LABEL } from '../../types'
/** 置信度细线:0~1 数值的低调可视化 */
function Meter({ value }: { value: number }) {
const pct = Math.max(0, Math.min(100, Math.round(value * 100)))
return (
<div className="h-px w-full bg-ink-200" role="presentation">
<div className="h-px bg-press transition-[width] duration-500" style={{ width: `${pct}%` }} />
</div>
)
}
/** 胜平负一行文字:选中的红字加方块标记,未选中的退灰 */
function OutcomeLine({
pick,
confidence,
}: {
pick: string | null
confidence: number | null
}) {
const options = ['1', 'X', '2'] as const
return (
<div>
<div className="flex items-baseline justify-center gap-6 sm:gap-10">
{options.map(o => {
const on = pick === o
return (
<div key={o} className="flex flex-col items-center gap-1">
<span className={`flex items-center gap-1.5 text-sm ${on ? 'font-semibold text-press' : 'text-ink-400'}`}>
{on && <span className="inline-block h-2 w-2 bg-press" aria-hidden="true" />}
{OUTCOME_LABEL[o]}
</span>
{on && confidence !== null && (
<span className="text-2xs tabular-nums text-ink-500">
{Math.round(confidence * 100)}%
</span>
)}
</div>
)
})}
</div>
{pick && confidence !== null && (
<div className="mx-auto mt-3 max-w-xs">
<Meter value={confidence} />
<p className="mt-1 text-center text-2xs text-ink-400">,</p>
</div>
)}
</div>
)
}
/** 预测成本展示:耗时 + token + 限流余量 */
function PredictionCost({ prediction }: { prediction: Prediction }) {
const latency = prediction.latency_ms != null ? `${(prediction.latency_ms / 1000).toFixed(1)}s` : null
const tokens = prediction.prompt_tokens != null || prediction.completion_tokens != null
? `${prediction.prompt_tokens ?? '?'}/${prediction.completion_tokens ?? '?'}`
: null
if (!latency && !tokens && prediction.rate_limit_remaining == null) return null
return (
<div className="border-t border-ink-200 pt-3 text-2xs text-ink-500">
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1">
{latency && (
<span className="inline-flex items-center gap-1">
<span aria-hidden="true" className="opacity-60"></span> {latency}
</span>
)}
{tokens && (
<span className="inline-flex items-center gap-1">
<span aria-hidden="true" className="opacity-60">Tok</span>prompt/completion: {tokens}
</span>
)}
{prediction.rate_limit_remaining != null && prediction.rate_limit_remaining <= 3 && (
<span className="text-press" title="每分钟最多 10 次预测">
: {prediction.rate_limit_remaining}/10()
</span>
)}
</div>
</div>
)
}
export function OutcomePanel({ prediction, match }: { prediction: Prediction; match: Match }) {
const homeName = match.home_team_zh || match.home_team
const awayName = match.away_team_zh || match.away_team
const degraded = prediction.status === 'degraded' || prediction.status === 'failed'
return (
<>
{!degraded && (
<div className="text-center">
<p className="font-serif text-5xl font-bold tabular-nums leading-none text-ink-900 sm:text-6xl">
{prediction.pred_home_goals ?? '-'}
<span className="mx-3 font-normal text-ink-300">:</span>
{prediction.pred_away_goals ?? '-'}
</p>
<p className="mt-3 text-2xs tracking-[0.5em] text-ink-400"></p>
{prediction.alt_pred_home_goals != null && prediction.alt_pred_away_goals != null && (
<p className="mt-2 text-2xs tabular-nums text-ink-400">
{' '}
<span className="font-serif text-sm font-bold tabular-nums text-ink-600">
{prediction.alt_pred_home_goals}<span className="mx-0.5 font-normal text-ink-300">:</span>{prediction.alt_pred_away_goals}
</span>
</p>
)}
</div>
)}
{!degraded && (
<div className="border-y border-ink-200 py-4">
<OutcomeLine pick={prediction.pred_1x2} confidence={prediction.subjective_confidence} />
</div>
)}
{!degraded && <PredictionCost prediction={prediction} />}
</>
)
}
@@ -0,0 +1,34 @@
/**
* ReasoningPanel: 终裁意见 / 降级原因 —— 预测的文本解释。
*
* P3-1: 从 MatchPredictPanel.PredictionPanel 拆出,渲染逻辑原样搬迁。
*/
import type { Prediction } from '../../types'
export function ReasoningPanel({ prediction }: { prediction: Prediction }) {
const degraded = prediction.status === 'degraded' || prediction.status === 'failed'
if (!prediction.reasoning) return null
// 降级态:reasoning 展示为「降级原因」
if (degraded) {
return (
<section>
<h4 className="section-head mb-2"></h4>
<blockquote className="border-l-2 border-press pl-4">
<p className="whitespace-pre-wrap font-serif text-sm leading-loose text-ink-700">{prediction.reasoning}</p>
</blockquote>
</section>
)
}
// 成功态:reasoning 展示为「终裁意见」
return (
<section>
<h4 className="section-head mb-3"></h4>
<blockquote className="border-l-2 border-press pl-4">
<p className="whitespace-pre-wrap font-serif text-sm leading-loose text-ink-700">{prediction.reasoning}</p>
</blockquote>
</section>
)
}
@@ -0,0 +1,38 @@
/**
* 公开站联赛列表(P1-3):优先请求 GET /api/v1/leagues,
* 失败或返回空数组则回退本地五大联赛常量(LEAGUES)。
*
* 显示名规则:常量里已有的 code 沿用中文标签(保持现有 UI 语言不变),
* 新增联赛用 API 返回的 name;排序按常量顺序优先、新联赛按 API 返回序追加。
* API 仅返回 {id, code, name, country},无敏感配置字段。
*/
import { useEffect, useState } from 'react'
import { fetchLeagues } from '../../../admin/dal'
import { LEAGUES } from '../types'
export function useLeagues(): { code: string; name: string }[] {
const [leagues, setLeagues] = useState(LEAGUES)
useEffect(() => {
let alive = true
;(async () => {
// dal.fetchLeagues 已兜底:网络/权限异常时返回 []
const rows = await fetchLeagues()
if (!alive || rows.length === 0) return
const zhName = new Map(LEAGUES.map(l => [l.code, l.name] as const))
const rank = new Map(LEAGUES.map((l, i) => [l.code, i] as const))
const merged = rows
.slice()
.sort(
(a, b) => (rank.get(a.code) ?? LEAGUES.length) - (rank.get(b.code) ?? LEAGUES.length),
)
.map(l => ({ code: l.code, name: zhName.get(l.code) ?? l.name }))
setLeagues(merged)
})()
return () => {
alive = false
}
}, [])
return leagues
}
@@ -0,0 +1,93 @@
/**
* useMatchPredict: 预测流程状态机(发起/进行中/结果/失败/关闭中止)。
*
* D3: 从 Matches.tsx 拆出。语义不变:
* - 连点防护:同一场比赛预测中再次点击直接忽略
* - 竞态防护:递增序号,过期响应丢弃
* - 关闭弹窗 = 中止在途请求 + 序号失效(catch/then 不再写入)
* - 5 分钟超时,与 nginx 代理 300s 对齐
*/
import { useRef, useState } from 'react'
import { http } from '../../../lib/http'
import type { Match, Prediction } from '../types'
/** 把后端/网络错误翻译成用户可读文案 */
function readablePredictError(e: unknown): string {
if (e instanceof Error) {
const m = e.message
if (/429/.test(m)) {
// 429 来自后端限流(每分钟 10 次),非上游 LLM
return '操作过于频繁:每分钟最多 10 次预测。为保护 LLM 额度,请稍后再试。'
}
if (/502/.test(m)) return 'LLM 服务暂时不可用(502),请稍后重试'
if (/402|Payment Required|额度|余额/.test(m)) return 'LLM 额度不足(402),请检查 API Key 余额'
if (/400|已完赛/.test(m)) return '该比赛已完赛,不再支持预测'
if (/409|已结算/.test(m)) return '该预测已结算,不能重新预测'
if (/timeout|超时|timed out/i.test(m)) return '请求超时,请稍后重试'
return m
}
return String(e)
}
interface UseMatchPredictOptions {
/** 共享 error state(拆分前列表与预测共用同一个 error,行为保持一致) */
onError: (msg: string | null) => void
}
export function useMatchPredict({ onError }: UseMatchPredictOptions) {
const [predictingId, setPredictingId] = useState<number | null>(null)
const [prediction, setPrediction] = useState<Prediction | null>(null)
const [predictionFor, setPredictionFor] = useState<Match | null>(null)
const predictSeq = useRef(0)
// 预测请求控制器:关闭弹窗时中止
const predictAbort = useRef<AbortController | null>(null)
function closePredict() {
predictAbort.current?.abort()
predictSeq.current++ // 令中止请求的 catch/then 全部失效,不再写入错误
setPredictingId(null)
setPrediction(null)
setPredictionFor(null)
onError(null)
}
const predict = async (m: Match) => {
// 防连点:若该场比赛已在预测中,直接忽略
if (predictingId === m.id) return
const seq = ++predictSeq.current
setPredictingId(m.id)
onError(null)
setPrediction(null)
setPredictionFor(m)
// LLM 多专家预测耗时可达数分钟,给足超时(与 nginx 代理 300s 对齐)
const controller = new AbortController()
predictAbort.current = controller
const timer = setTimeout(() => controller.abort(), 300_000)
try {
const data = await http.post<Prediction>('/predict', { match_id: m.id, mode: 'multi' }, {
timeoutMs: 300_000,
signal: controller.signal,
})
if (seq !== predictSeq.current) return
setPrediction(data)
} catch (e) {
if (seq !== predictSeq.current) return
onError(
e instanceof DOMException && e.name === 'AbortError'
? '预测超时(5 分钟),请稍后重试'
: readablePredictError(e),
)
} finally {
clearTimeout(timer)
if (seq === predictSeq.current) setPredictingId(null)
}
}
return {
predictingId,
prediction,
predictionFor,
predict,
closePredict,
}
}
@@ -0,0 +1,94 @@
/**
* useMatchesList: 赛程列表数据获取(筛选/游标分页/进行中比赛)。
*
* D3: 从 Matches.tsx 拆出。竞态防护语义不变 —— 递增序号只认最后一次请求;
* loadMore 不自增序号(切换筛选才自增,翻页跟随当前序列)。
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import { http } from '../../../lib/http'
import type { Match } from '../types'
interface UseMatchesListOptions {
/** 共享 error state(拆分前列表与预测共用同一个 error,行为保持一致) */
onError: (msg: string | null) => void
}
export function useMatchesList({ onError }: UseMatchesListOptions) {
const [league, setLeague] = useState('E0')
const [status, setStatus] = useState('scheduled')
const [matches, setMatches] = useState<Match[]>([])
const [nextCursor, setNextCursor] = useState<string | null>(null)
const [loadingMore, setLoadingMore] = useState(false)
const [loading, setLoading] = useState(false)
const [showAllUpcoming, setShowAllUpcoming] = useState(false) // 默认仅展示未来 3 天;true 展开全部
const [liveMatches, setLiveMatches] = useState<Match[]>([]) // 进行中比赛(顶部独立区块)
// 请求竞态防护:切换联赛/状态很快时,先发的慢请求可能后返回,
// 把旧结果覆盖到新筛选上。用递增序号只认最后一次请求的响应。
const loadSeq = useRef(0)
const load = useCallback(async () => {
const seq = ++loadSeq.current
setLoading(true)
setLoadingMore(false)
setShowAllUpcoming(false) // 切换筛选重置为「未来 3 天」视图
onError(null)
try {
const params = new URLSearchParams({ league, status, limit: '50' })
const data = await http.get<{ items: Match[]; next_cursor: string | null }>(`/matches?${params}`)
if (seq !== loadSeq.current) return // 已有更新的请求,丢弃本次结果
setMatches(data.items)
setNextCursor(data.next_cursor ?? null)
} catch (e) {
if (seq !== loadSeq.current) return
onError(e instanceof Error ? e.message : String(e))
} finally {
if (seq === loadSeq.current) setLoading(false)
}
}, [league, status, onError])
// 加载下一页(游标分页)
const loadMore = async () => {
if (!nextCursor || loadingMore) return
const seq = loadSeq.current // 不做自增:切换筛选会自增,这里只跟随当前序列
setLoadingMore(true)
try {
const params = new URLSearchParams({ league, status, limit: '50', cursor: nextCursor })
const data = await http.get<{ items: Match[]; next_cursor: string | null }>(`/matches?${params}`)
if (seq !== loadSeq.current) return
setMatches(prev => [...prev, ...data.items])
setNextCursor(data.next_cursor ?? null)
} catch (e) {
if (seq !== loadSeq.current) return
onError(e instanceof Error ? e.message : String(e))
} finally {
if (seq === loadSeq.current) setLoadingMore(false)
}
}
// 加载进行中比赛(顶部独立区块)
const loadLive = useCallback(async () => {
try {
const params = new URLSearchParams({ league, status: 'in_play', limit: '20' })
const data = await http.get<{ items: Match[] }>(`/matches?${params}`)
setLiveMatches(data.items ?? [])
} catch {
/* ignore:进行中非核心功能 */
}
}, [league])
useEffect(() => { load(); loadLive() }, [load, loadLive])
return {
league, setLeague,
status, setStatus,
matches,
nextCursor,
loading,
loadingMore,
showAllUpcoming, setShowAllUpcoming,
liveMatches,
load,
loadMore,
}
}
+100
View File
@@ -0,0 +1,100 @@
/**
* Matches 页面族共享类型与常量。
*
* D3(工程债): Matches.tsx 原本 ~1400 行,类型/常量/预测面板/详情面板/
* 列表逻辑全部内联。本文件是拆分后的共享层 —— 只放数据契约与纯常量,
* 不含 React 组件。
*/
/** /matches 列表项(公开接口) */
export interface Match {
id: number
league_code: string | null
season: string | null
home_team: string
away_team: string
home_team_zh: string | null
away_team_zh: string | null
match_date: string
match_status: string
home_goals: number | null
away_goals: number | null
match_stage: string | null
home_xg: number | null
away_xg: number | null
}
/** POST /predict 响应 */
export interface Prediction {
prediction_id: number
provider: string
model: string
prompt_version: string | null
mode: string
pred_home_goals: number | null
pred_away_goals: number | null
alt_pred_home_goals: number | null
alt_pred_away_goals: number | null
pred_1x2: string | null
subjective_confidence: number | null
reasoning: string | null
status: string
agent_outputs: AgentReport[] | null
agent_weights: Record<string, number> | null
context: string
latency_ms: number | null
prompt_tokens: number | null
completion_tokens: number | null
rate_limit_remaining: number | null
}
/** 多专家单路报告 */
export interface AgentReport {
agent: string
status: string
data_sufficiency: string
analysis: string
home_edge: number | null
subjective_confidence: number | null
key_evidence: string[]
exp_home_goals: number | null
exp_away_goals: number | null
probable_score: string | null
model: string
latency_ms: number | null
}
export const AGENT_LABELS: Record<string, string> = {
h2h: '历史交锋分析专家',
form: '近期状态分析专家',
stats: '攻防数据分析专家',
home_away: '主客因素分析专家',
standings: '联赛排名分析专家',
}
export const LEAGUES = [
{ code: 'E0', name: '英超' },
{ code: 'SP1', name: '西甲' },
{ code: 'D1', name: '德甲' },
{ code: 'I1', name: '意甲' },
{ code: 'F1', name: '法甲' },
{ code: 'CL', name: '欧冠' },
{ code: 'EL', name: '欧联' },
]
/** 汉字编号,给专家意见排版用 */
export const CN_NUM = ['一', '二', '三', '四', '五', '六', '七', '八']
export const STATUS_META: Record<string, { label: string; cls: string }> = {
finished: { label: '已完赛', cls: 'text-ink-400' },
scheduled: { label: '未开赛', cls: 'text-ink-600' },
// 键与 normalize.py 的 VALID_STATUS 对齐: 库里存的是 in_play(上游 live 被归一化),不存在 'live' 状态
in_play: { label: '进行中', cls: 'text-press font-medium' },
paused: { label: '暂停', cls: 'text-press font-medium' },
postponed: { label: '延期', cls: 'text-ink-400' },
cancelled: { label: '取消', cls: 'text-ink-400' },
suspended: { label: '中止', cls: 'text-ink-400' },
}
/** 1x2 → 中文标签 */
export const OUTCOME_LABEL: Record<string, string> = { '1': '主胜', X: '平局', '2': '客胜' }
+114
View File
@@ -0,0 +1,114 @@
/**
* Matches 页面族共享的原子 UI 小件(无业务状态)。
*
* D3: 从 Matches.tsx 内联定义上移到模块级 —— Switch 原先定义在组件函数
* 体内(每次渲染重建组件对象),它没有内部 state,提升后渲染结果一致。
*/
import type { Match } from './types'
export function Spinner({ className = '' }: { className?: string }) {
return (
<svg
viewBox="0 0 20 20"
className={`h-3.5 w-3.5 animate-spin ${className}`}
fill="none"
aria-hidden="true"
>
<circle cx="10" cy="10" r="7.5" stroke="currentColor" strokeWidth="1.5" strokeOpacity="0.25" />
<path d="M17.5 10A7.5 7.5 0 0010 2.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)
}
/** 骨架占位行:低调脉动灰块 */
export function SkeletonRows({ n = 4 }: { n?: number }) {
return (
<>
{Array.from({ length: n }).map((_, i) => (
<div key={i} className="flex items-center gap-4 border-b border-ink-200 px-1 py-3.5">
<div className="skeleton h-3 w-16" />
<div className="skeleton h-3 flex-1" />
<div className="skeleton h-3 w-10" />
<div className="skeleton h-3 flex-1" />
<div className="skeleton h-3 w-16" />
</div>
))}
</>
)
}
/** 状态/模式一组的文字切换 */
export function Switch({ value, onChange, items }: {
value: string
onChange: (v: string) => void
items: { v: string; label: string; title?: string }[]
}) {
return (
<span className="inline-flex items-center gap-2.5">
{items.map((it, i) => (
<span key={it.v} className="inline-flex items-center gap-2.5">
{i > 0 && <span className="text-ink-300" aria-hidden="true">/</span>}
<button
onClick={() => onChange(it.v)}
title={it.title}
className={`relative tab ${value === it.v ? 'tab-on' : ''} text-xs`}
>
{it.label}
</button>
</span>
))}
</span>
)
}
/** 日期分组头显示:今日/明天/周几 · 年月日 */
export function formatDateHeader(dateKey: string): string {
if (!dateKey) return '未开赛'
const d = new Date(dateKey + 'T00:00:00')
if (isNaN(d.getTime())) return dateKey
const today = new Date()
const todayKey = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
const tmr = new Date(today)
tmr.setDate(tmr.getDate() + 1)
const tmrKey = `${tmr.getFullYear()}-${String(tmr.getMonth() + 1).padStart(2, '0')}-${String(tmr.getDate()).padStart(2, '0')}`
const weekday = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][d.getDay()]
if (dateKey === todayKey) return `今日 ${weekday}`
if (dateKey === tmrKey) return `明日 ${weekday}`
return `${d.getMonth() + 1}${d.getDate()}${weekday}`
}
/** UTC ISO → 本地日期 YYYY-MM-DD(用于分组) */
export function toLocalDateKey(iso: string): string {
const d = new Date(iso)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
/** 按本地日期分组(非 UTC),保持时间序 */
export function groupByDate(list: Match[]): Array<[string, Match[]]> {
const map = new Map<string, Match[]>()
for (const m of list) {
const key = toLocalDateKey(m.match_date)
const arr = map.get(key)
if (arr) arr.push(m)
else map.set(key, [m])
}
return [...map.entries()]
}
/** 日期 key 辅助:YYYY-MM-DD(本地时区) */
function dateKey(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
/** 未来 3 天窗口:今天 00:00 → 第 3 天 00:00(即今天/明天/后天) */
function addDays(d: Date, n: number): string {
const x = new Date(d)
x.setFullYear(x.getFullYear(), x.getMonth(), x.getDate() + n)
return dateKey(x)
}
/** 比赛是否在未来 3 天内(用于默认视图过滤) */
export function withinNext3Days(matchDate: string): boolean {
const key = toLocalDateKey(matchDate)
return key >= dateKey(new Date()) && key < addDays(new Date(), 3)
}
+2
View File
@@ -32,6 +32,8 @@ export default {
},
},
fontFamily: {
// 毛体草书(国内 CDN)+ 粗楷体回退
brush: ['"Liu Jian Mao Cao"', '"KaiTi"', '"STKaiti"', '"Kaiti SC"', '"楷体"', '"Songti SC"', 'serif'],
// 标题与比分:宋体血统,报纸版面的骨架
serif: [
'Georgia',
+1
View File
@@ -14,6 +14,7 @@ dependencies = [
"httpx>=0.27",
"alembic>=1.13",
"cryptography>=42.0",
"croniter>=2.0",
]
[project.optional-dependencies]
+65
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import logging
import os
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
@@ -22,11 +23,73 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
migrate_plaintext_sensitive_settings,
)
from src.core.security_check import assert_security_on_startup
from src.core.scheduler import scheduler, quality_scheduler
from src.api.routes.schedules import _run_scheduled_task
from src.data.config import BZZOIRO_LEAGUE_IDS
await init_db() # 验证连接,不建表
await migrate_plaintext_sensitive_settings() # 明文敏感配置 → 加密(幂等)
await ensure_admin_password_hashed() # .env 明文密码 → scrypt 哈希(幂等)
await assert_security_on_startup() # 启动安全校验(生产拒绝/开发警告)
# D7(工程债): 进程内限流(_RateLimiter)与 KeyRing 均为单进程状态;
# 多 worker 部署时各进程独立计数,限流阈值会按 worker 数放大、KeyRing 不共享。
# 生产环境应将限流前置到 Nginx/网关,或以单 worker 运行(见 src/api/deps.py 注释)。
# 此处仅提醒一次,不阻断启动,也不引入 Redis 等外部依赖。
if settings.APP_ENV == "production":
logger.warning(
"APP_ENV=production: 进程内 rate-limit 与 KeyRing 仅单进程有效;"
"多 worker 部署请将限流前置到 Nginx/网关,或以单 worker 运行"
)
# P3-3:STRICT_SINGLE_WORKER 启动期强制校验,拒绝多 worker 静默配额漂移。
# uvicorn 通过 --workers 传入;此处以环境变量 UVICORN_WORKERS 或启动参数判定。
# 为避免耦合 uvicorn 内部,仅校验一个显式传入的标记:当 STRICT_SINGLE_WORKER=True 时,
# 要求环境变量 UVICORN_WORKERS 不为空且 <=1,否则拒绝启动。
if settings.STRICT_SINGLE_WORKER:
workers = os.environ.get("UVICORN_WORKERS", "1")
try:
n_workers = int(workers)
except ValueError:
n_workers = 1
if n_workers > 1:
raise RuntimeError(
f"STRICT_SINGLE_WORKER=True 但以 {n_workers} worker 启动会被拒绝 "
f"(应用内限流/KeyRing 多 worker 下各自独立计数,配额放大 {n_workers} 倍)。"
f"请前置 Nginx/网关全局限流后再启用多 worker,或保持单 worker。"
)
logger.info("STRICT_SINGLE_WORKER=True:已确认单 worker 启动,限流配额不会漂移")
# 注册默认定时任务(如果数据库中没有)
from src.db.base import AsyncSessionLocal
from sqlalchemy import select
from src.db.models import Schedule
async with AsyncSessionLocal() as session:
existing = (await session.execute(select(Schedule.id))).scalars().all()
if "daily-events" not in existing:
session.add(Schedule(id="daily-events", task="events", cron="0 8 * * *", enabled=False))
if "daily-standings" not in existing:
session.add(Schedule(id="daily-standings", task="standings", cron="0 9 * * *", enabled=False))
if "daily-stats" not in existing:
session.add(Schedule(id="daily-stats", task="stats", cron="*/30 * * * *", enabled=False))
await session.commit()
# 从数据库加载所有启用的定时任务
async with AsyncSessionLocal() as session:
schedules = (await session.execute(select(Schedule).where(Schedule.enabled))).scalars().all()
for s in schedules:
leagues = s.leagues.split(",") if s.leagues else list(BZZOIRO_LEAGUE_IDS.keys())
scheduler.register(
s.id, s.cron,
lambda sid=s.id: _run_scheduled_task(sid),
enabled=s.enabled,
)
await scheduler.start()
await quality_scheduler.start()
logger.info("应用启动完成")
yield
await scheduler.stop()
await quality_scheduler.stop()
await close_client()
@@ -62,6 +125,7 @@ def create_app() -> FastAPI:
from src.api.routes.backtest import router as backtest_router
from src.api.routes.auth import router as auth_router
from src.api.routes.admin_settings import router as admin_settings_router
from src.api.routes.schedules import router as schedules_router
app.include_router(matches_router)
app.include_router(predict_router)
@@ -70,6 +134,7 @@ def create_app() -> FastAPI:
app.include_router(backtest_router)
app.include_router(auth_router)
app.include_router(admin_settings_router)
app.include_router(schedules_router)
@app.get("/health")
async def health():
+1 -1
View File
@@ -187,7 +187,7 @@ async def rate_limit_predict(request: Request) -> None:
client_ip = get_client_ip(request)
if not _predict_limiter.is_allowed(client_ip):
logger.warning("rate limit exceeded for %s", ip)
logger.warning("rate limit exceeded for %s", client_ip)
raise HTTPException(
status_code=429,
detail="请求过于频繁,请稍后再试(每分钟最多 10 次)",
+88
View File
@@ -0,0 +1,88 @@
"""后台管理:配置项 CRUD(settings)与运行日志查询。
所有接口需管理员鉴权(require_admin)。路由前缀 /api/v1/admin。
配置项白名单见 src/core/runtime_config.py SETTING_DEFS,之外的 key 一律拒绝。
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from src.api.deps import require_admin
from src.core.log_buffer import get_entries
from src.core.runtime_config import (
SETTING_DEFS,
clear_runtime_value,
get_setting_origin,
mask_value,
set_runtime_value,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/admin", tags=["admin"], dependencies=[Depends(require_admin)])
class SettingUpdateIn(BaseModel):
value: str
@router.get("/settings")
async def list_settings():
"""全部可配置项(脱敏),供后台各配置页渲染。"""
out = []
for key, defn in SETTING_DEFS.items():
origin, value = await get_setting_origin(key)
out.append(
{
"key": key,
"label": defn.label,
"description": defn.description,
"sensitive": defn.sensitive,
"configured": origin != "none",
"masked": mask_value(value, defn.sensitive),
"origin": origin,
}
)
return out
@router.get("/logs")
async def read_logs(
level: str | None = Query(None, description="最低级别: DEBUG/INFO/WARNING/ERROR"),
keyword: str | None = Query(None, description="消息或 logger 关键字"),
limit: int = Query(200, ge=1, le=1000),
):
"""查询应用运行日志(内存环形缓冲,最新在前;进程重启后清零)。"""
entries = get_entries(level, keyword, limit)
return {"entries": entries, "count": len(entries)}
@router.put("/settings/{key}")
async def update_setting(key: str, body: SettingUpdateIn):
"""更新配置项(写入 app_settings 覆盖 .env)。传空值请改用 DELETE。"""
if key not in SETTING_DEFS:
raise HTTPException(404, f"不支持的配置项: {key}")
value = body.value.strip()
if not value:
raise HTTPException(400, "值不能为空;如需回落 .env 请调用清除接口")
await set_runtime_value(key, value)
defn = SETTING_DEFS[key]
return {"key": key, "masked": mask_value(value, defn.sensitive), "origin": "db"}
@router.delete("/settings/{key}")
async def clear_setting(key: str):
"""清除 DB 覆盖值,回落 .env 默认。"""
if key not in SETTING_DEFS:
raise HTTPException(404, f"不支持的配置项: {key}")
await clear_runtime_value(key)
origin, value = await get_setting_origin(key)
defn = SETTING_DEFS[key]
return {
"key": key,
"masked": mask_value(value, defn.sensitive),
"origin": origin,
}
+228
View File
@@ -0,0 +1,228 @@
"""后台管理:数据源列表/连通性测试、KeyRing 状态、采集健康概览。
所有接口需管理员鉴权(require_admin)。路由前缀 /api/v1/admin。
"""
from __future__ import annotations
import logging
import time
from datetime import date, datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func, select
from src.api.deps import require_admin
from src.core.config import settings
from src.core.http_client import get_client
from src.core.runtime_config import (
SETTING_DEFS,
get_runtime_value,
get_setting_origin,
mask_value,
)
from src.db.base import AsyncSession, get_db_read
from src.db.models import Match, MatchStats, Standing
from src.data.key_ring import get_key_ring
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/admin", tags=["admin"], dependencies=[Depends(require_admin)])
# ── 数据源元数据(bzzoiro 单一数据源) ────────────────────────────
_SOURCES: list[dict] = [
{
"name": "bzzoiro",
"label": "Bzzoiro",
"description": "唯一数据源:赛程比分 + 积分榜 + 比赛详细统计(xG/射门/控球等)",
"setting_keys": ["BZZOIRO_KEY", "BZZOIRO_BASE"],
},
]
async def _last_ingestion(db: AsyncSession, source: str) -> datetime | None:
"""源最近一次采集时间(取自数据血缘字段,无记录返回 None)。"""
return (
await db.execute(
select(func.max(MatchStats.retrieved_at)).where(MatchStats.source == source)
)
).scalar()
@router.get("/datasources")
async def list_datasources(db: AsyncSession = Depends(get_db_read)):
"""数据源列表:各配置项的脱敏值、来源(db/env/none)与最近采集时间。"""
result = []
for src in _SOURCES:
settings_out = []
for key in src["setting_keys"]:
origin, value = await get_setting_origin(key)
defn = SETTING_DEFS[key]
settings_out.append(
{
"key": key,
"label": defn.label,
"description": defn.description,
"sensitive": defn.sensitive,
"configured": origin != "none",
"masked": mask_value(value, defn.sensitive),
"origin": origin,
}
)
key_configured = all(s["configured"] for s in settings_out) if settings_out else True
last = await _last_ingestion(db, src["name"])
result.append(
{
"name": src["name"],
"label": src["label"],
"description": src["description"],
"key_configured": key_configured,
"last_ingestion": last.isoformat() if last else None,
"settings": settings_out,
}
)
return result
# ── 连通性测试 ──────────────────────────────────────────────────
_TEST_TIMEOUT = 15
async def _probe(url: str, headers: dict | None = None, params: dict | None = None) -> dict:
"""单次 HTTP 探测,返回 (ok, status, latency_ms, detail)。不重试。"""
client = get_client()
start = time.monotonic()
try:
resp = await client.get(url, headers=headers, params=params, timeout=_TEST_TIMEOUT)
except Exception as e:
return {
"ok": False,
"status": None,
"latency_ms": int((time.monotonic() - start) * 1000),
"detail": f"无法连接: {e}",
}
latency = int((time.monotonic() - start) * 1000)
status = resp.status_code
if status == 200:
detail = "连接成功"
elif status in (401, 403):
detail = "服务可达,但密钥无效或无权限"
else:
detail = f"服务返回 HTTP {status}"
return {"ok": status == 200, "status": status, "latency_ms": latency, "detail": detail}
@router.post("/datasources/{name}/test")
async def test_datasource(name: str):
"""轻量连通性测试:真实请求上游一次,不触发任何入库。"""
src = next((s for s in _SOURCES if s["name"] == name), None)
if src is None:
raise HTTPException(404, f"未知数据源: {name}")
if name == "bzzoiro":
key = await get_runtime_value("BZZOIRO_KEY")
if not key:
return {"ok": False, "status": None, "latency_ms": 0, "detail": "BZZOIRO_KEY 未配置"}
base = (await get_runtime_value("BZZOIRO_BASE")).rstrip("/")
today = date.today().isoformat()
return await _probe(
f"{base}/events/",
headers={"Authorization": f"Token {key}", "Accept": "application/json"},
params={"date_from": today, "date_to": today},
)
raise HTTPException(404, f"未知数据源: {name}")
# ── 数据源健康/最近采集状态(只读,不触发采集) ──────────────────────
@router.get("/ingest/status")
async def ingest_status(db: AsyncSession = Depends(get_db_read)):
"""数据源采集健康概览(bzzoiro 单源;只读,不触发任何采集)。"""
bzzoiro_key = await get_runtime_value("BZZOIRO_KEY")
bzzoiro_base = await get_runtime_value("BZZOIRO_BASE")
# 比赛覆盖
match_row = (
await db.execute(
select(
func.count().label("cnt"),
func.max(Match.match_date).label("latest_match_date"),
func.max(Match.created_at).label("latest_row_at"),
).where(Match.match_status == "finished")
)
).one()
# 统计覆盖(精确 retrieved_at)
stats_row = (
await db.execute(
select(
func.count().label("cnt"),
func.max(MatchStats.retrieved_at).label("latest_retrieved"),
).where(MatchStats.source == "bzzoiro")
)
).one()
# 积分榜覆盖
standings_row = (
await db.execute(select(func.count()).select_from(Standing))
).scalar()
bzzoiro = {
"name": "bzzoiro",
"label": "Bzzoiro",
"key_configured": bool(bzzoiro_key),
"base_url": (bzzoiro_base.rstrip("/") if bzzoiro_base else None) or settings.BZZOIRO_BASE,
"reachable": None, # 不主动探测
"last_success_at": (stats_row.latest_retrieved or match_row.latest_row_at),
"last_success_at_iso": (
stats_row.latest_retrieved or match_row.latest_row_at
).isoformat() if (stats_row.latest_retrieved or match_row.latest_row_at) else None,
"latest_match_date": match_row.latest_match_date.isoformat() if match_row.latest_match_date else None,
"recent_count": match_row.cnt or 0,
"stats_count": stats_row.cnt or 0,
"standings_count": standings_row or 0,
"note": "last_success_at 取 match_stats.retrieved_at(统计回填)与 matches.created_at(比赛行)的较大者",
"last_failure": _last_failure_log("bzzoiro"),
}
return {"sources": [bzzoiro]}
@router.get("/keyring/status")
async def keyring_status():
"""KeyRing 运行状态:当前使用的 key、冷却状态、轮转信息(供管理后台展示)。"""
base = (await get_runtime_value("BZZOIRO_BASE")).rstrip("/")
raw_keys = await get_runtime_value("BZZOIRO_KEY")
ring = get_key_ring(base, raw_keys)
st = ring.stats()
st["base_url"] = base
st["cooldown_seconds"] = ring._cooldown
st["has_multiple"] = ring.has_multiple
st["active_key"] = ring.active_key
return st
@router.post("/keyring/cooldown/reset")
async def keyring_reset_cooldown():
"""手动重置所有 key 的冷却状态(用于紧急恢复)。"""
base = (await get_runtime_value("BZZOIRO_BASE")).rstrip("/")
raw_keys = await get_runtime_value("BZZOIRO_KEY")
ring = get_key_ring(base, raw_keys)
ring._blocked_until.clear()
return {"ok": True, "message": "已重置所有 key 冷却状态", "stats": ring.stats()}
def _last_failure_log(source: str) -> dict | None:
"""从系统日志缓冲中查找某数据源的最近一次错误(仅作参考,非专用失败表)。"""
from src.core.log_buffer import get_entries
entries = get_entries(min_level="ERROR", keyword=source, limit=5)
if not entries:
return None
e = entries[0]
return {
"at": datetime.fromtimestamp(e["ts"]).isoformat(),
"logger": e["logger"],
"detail": e["message"][:200],
"note": "approx:来自内存日志缓冲,非专用采集失败表;进程重启后清零",
}
+55
View File
@@ -0,0 +1,55 @@
"""后台管理:采集任务状态查询(只读)。
GET /api/v1/admin/ingest/jobs/{job_id} — 单任务详情
GET /api/v1/admin/ingest/jobs?limit=N — 最近任务列表(默认 20)
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import desc, select
from src.api.deps import require_admin
from src.api.schemas import IngestJobOut
from src.db.base import AsyncSession, get_db_read
from src.db.models import IngestJob
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/admin", tags=["admin"], dependencies=[Depends(require_admin)])
@router.get("/ingest/jobs/{job_id}", response_model=IngestJobOut)
async def get_ingest_job(job_id: str, db: AsyncSession = Depends(get_db_read)):
"""查询单个采集任务状态。"""
job = await db.get(IngestJob, job_id)
if job is None:
raise HTTPException(404, f"采集任务不存在: {job_id}")
return _job_to_out(job)
@router.get("/ingest/jobs", response_model=list[IngestJobOut])
async def list_ingest_jobs(
limit: int = Query(20, ge=1, le=100, description="返回条数"),
db: AsyncSession = Depends(get_db_read),
):
"""查询最近采集任务(最新在前)。"""
rows = (
await db.execute(select(IngestJob).order_by(desc(IngestJob.created_at)).limit(limit))
).scalars().all()
return [_job_to_out(j) for j in rows]
def _job_to_out(job: IngestJob) -> IngestJobOut:
return IngestJobOut(
id=job.id,
task=job.task,
params=job.params or {},
status=job.status,
result=job.result,
error=job.error,
created_at=job.created_at,
started_at=job.started_at,
finished_at=job.finished_at,
)
+126
View File
@@ -0,0 +1,126 @@
"""后台管理:LLM 专家/终裁配置、可用模型探测、连通性测试。
所有接口需管理员鉴权(require_admin)。路由前缀 /api/v1/admin。
"""
from __future__ import annotations
import logging
import time
import httpx
from fastapi import APIRouter, Depends
from src.api.deps import require_admin
from src.core.config import settings
from src.core.http_client import get_client
from src.core.runtime_config import (
AGENT_META,
SETTING_DEFS,
get_runtime_value,
get_setting_origin,
mask_value,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/admin", tags=["admin"], dependencies=[Depends(require_admin)])
@router.get("/llm/agents")
async def list_llm_agents():
"""各专家/终裁的独立 LLM 配置状态(含当前生效模型的解析结果)。"""
out = []
for agent in AGENT_META:
aid = agent["id"].upper()
pfx = f"AGENT_{aid}_"
fields = {}
for suffix in ("MODEL", "BASE_URL", "API_KEY"):
origin, value = await get_setting_origin(f"{pfx}{suffix}")
defn = SETTING_DEFS[f"{pfx}{suffix}"]
fields[suffix.lower()] = {
"configured": origin != "none",
"masked": mask_value(value, defn.sensitive),
"origin": origin,
}
# 生效模型 = 覆盖 → 层级默认(专家/终裁 env) → 全局 LLM_MODEL
tier_default = (
settings.LLM_AGGREGATOR_MODEL if agent["id"] == "aggregator" else settings.LLM_SPECIALIST_MODEL
)
effective_model = (
fields["model"]["masked"]
if fields["model"]["configured"]
else (tier_default or await get_runtime_value("LLM_MODEL"))
)
out.append(
{
"id": agent["id"],
"label": agent["label"],
"fields": fields,
"effective_model": effective_model,
}
)
return out
@router.get("/llm/models")
async def list_llm_models():
"""探测当前 LLM 服务可用的模型列表(OpenAI 兼容 GET /models)。
只读探测,不产生费用;配置缺失或服务不可达时返回 ok=false 与原因。
"""
base_url = (await get_runtime_value("LLM_BASE_URL")).rstrip("/")
api_key = await get_runtime_value("LLM_API_KEY")
if not base_url or not api_key:
return {"ok": False, "models": [], "detail": "LLM_BASE_URL 或 LLM_API_KEY 未配置"}
client = get_client()
start = time.monotonic()
try:
resp = await client.get(
f"{base_url}/models",
headers={"Authorization": f"Bearer {api_key}"},
timeout=httpx.Timeout(connect=10.0, read=20.0, write=10.0, pool=10.0),
)
except Exception as e:
return {
"ok": False,
"models": [],
"latency_ms": int((time.monotonic() - start) * 1000),
"detail": f"无法连接 LLM 服务: {e}",
}
latency = int((time.monotonic() - start) * 1000)
if resp.status_code in (401, 403):
return {"ok": False, "models": [], "latency_ms": latency, "detail": "密钥无效或无权限(HTTP 401/403)"}
if resp.status_code != 200:
return {"ok": False, "models": [], "latency_ms": latency, "detail": f"服务返回 HTTP {resp.status_code}"}
try:
data = resp.json()
except Exception:
return {"ok": False, "models": [], "latency_ms": latency, "detail": "响应不是合法 JSON"}
models: list[str] = []
items = data.get("data") if isinstance(data, dict) else None
if isinstance(items, list):
models = sorted(
str(m.get("id")) for m in items if isinstance(m, dict) and m.get("id")
)
if not models:
return {"ok": False, "models": [], "latency_ms": latency, "detail": "服务未返回模型列表"}
return {"ok": True, "models": models, "latency_ms": latency, "detail": f"{len(models)} 个可用模型"}
@router.post("/llm/ping")
async def llm_ping():
"""LLM 连通性测试(不依赖比赛)。只发一次 chat 请求验证配置。"""
from src.llm.provider import get_default_provider
p = await get_default_provider()
resp = await p.chat(
system="你是测试助手。",
user="ping",
max_tokens=10,
)
if resp.error:
return {"ok": False, "message": resp.error}
return {"ok": True, "message": "LLM 连接正常", "model": p.model}
+343
View File
@@ -0,0 +1,343 @@
"""后台管理:管理区统计、数据完整性分析、数据质量检查。
所有接口需管理员鉴权(require_admin)。路由前缀 /api/v1/admin。
"""
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends
from sqlalchemy import func, select
from src.api.deps import require_admin
from src.db.base import AsyncSession, get_db_read
from src.db.models import DataQualityCheck, IngestFailure, League, Match, MatchStats, Prediction, Standing
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/admin", tags=["admin"], dependencies=[Depends(require_admin)])
@router.get("/stats")
async def admin_stats(db: AsyncSession = Depends(get_db_read)):
"""管理区统计(只读):预测次数 + 比赛覆盖。轻量聚合,无 LLM 调用。"""
day_ago = datetime.now(timezone.utc) - timedelta(days=1)
week_ago = datetime.now(timezone.utc) - timedelta(days=7)
r = (
await db.execute(
select(
func.count().label("total"),
func.count().filter(Prediction.created_at >= day_ago).label("last_24h"),
func.count().filter(Prediction.created_at >= week_ago).label("last_7d"),
)
)
).one()
# F3 修复: 补充真实比赛计数(非 limit=100 近似)
match_cnt = (await db.execute(select(func.count()).select_from(Match))).scalar() or 0
finished_cnt = (await db.execute(select(func.count()).where(Match.match_status == "finished"))).scalar() or 0
stats_cnt = (await db.execute(select(func.count()).select_from(MatchStats))).scalar() or 0
standings_cnt = (await db.execute(select(func.count()).select_from(Standing))).scalar() or 0
return {
"predictions": {"total": r.total, "last_24h": r.last_24h, "last_7d": r.last_7d},
"matches": {"total": match_cnt, "finished": finished_cnt},
"stats": {"total": stats_cnt},
"standings": {"total": standings_cnt},
}
# ── 数据完整性分析(可视化数据源) ────────────────────────────────
@router.get("/data-completeness")
async def data_completeness(db: AsyncSession = Depends(get_db_read)):
"""按联赛统计数据完整性:比赛覆盖、字段覆盖、积分榜覆盖。
前端「数据完整性」页据此渲染,回答三个问题:
1. 数据是否齐全(各联赛比赛/统计/积分榜量级)
2. 字段是否齐全(每张统计表各字段非空率)
3. 覆盖是否新鲜(最近一场/最近一次采集)
"""
from src.data.config import BZZOIRO_LEAGUE_IDS, LEAGUE_NAMES, LEAGUE_COUNTRIES
out_leagues: list[dict] = []
for code, bzz_id in BZZOIRO_LEAGUE_IDS.items():
# 比赛覆盖
m = (
await db.execute(
select(
func.count().label("total"),
func.count().filter(Match.match_status == "finished").label("finished"),
func.count().filter(Match.match_status == "scheduled").label("scheduled"),
func.count().filter(Match.source_event_id.is_not(None)).label("with_source_id"),
func.max(Match.match_date).label("latest_match"),
func.min(Match.match_date).label("earliest_match"),
)
.select_from(Match)
.join(League, League.id == Match.league_id)
.where(League.code == code)
)
).one()
# 统计字段覆盖(联表 matches)
s = (
await db.execute(
select(
func.count().label("rows"),
func.count(MatchStats.home_xg).label("xg"),
func.count(MatchStats.home_shots).label("shots"),
func.count(MatchStats.home_possession).label("possession"),
func.count(MatchStats.home_corners).label("corners"),
func.count(MatchStats.home_fouls).label("fouls"),
func.count(MatchStats.home_big_chances).label("big_chances"),
func.count(MatchStats.home_yellow_cards).label("cards"),
)
.select_from(MatchStats)
.join(Match, Match.id == MatchStats.match_id)
.join(League, League.id == Match.league_id)
.where(League.code == code)
)
).one()
# 积分榜覆盖
st = (
await db.execute(
select(
func.count().label("rows"),
func.max(Standing.retrieved_at).label("latest_retrieved"),
)
.select_from(Standing)
.join(League, League.id == Standing.league_id)
.where(League.code == code)
)
).one()
stats_rows = s.rows or 0
pct = lambda n: round(n / stats_rows * 100, 1) if stats_rows else 0.0 # noqa: E731
out_leagues.append(
{
"code": code,
"name": LEAGUE_NAMES.get(code, code),
"country": LEAGUE_COUNTRIES.get(code),
"matches": {
"total": m.total or 0,
"finished": m.finished or 0,
"scheduled": m.scheduled or 0,
"with_source_id": m.with_source_id or 0,
"earliest_match": m.earliest_match.isoformat() if m.earliest_match else None,
"latest_match": m.latest_match.isoformat() if m.latest_match else None,
},
"stats": {
"rows": stats_rows,
"fields": {
"xg": {"count": s.xg or 0, "pct": pct(s.xg or 0)},
"shots": {"count": s.shots or 0, "pct": pct(s.shots or 0)},
"possession": {"count": s.possession or 0, "pct": pct(s.possession or 0)},
"corners": {"count": s.corners or 0, "pct": pct(s.corners or 0)},
"fouls": {"count": s.fouls or 0, "pct": pct(s.fouls or 0)},
"big_chances": {"count": s.big_chances or 0, "pct": pct(s.big_chances or 0)},
"cards": {"count": s.cards or 0, "pct": pct(s.cards or 0)},
},
},
"standings": {
"rows": st.rows or 0,
"latest_retrieved": st.latest_retrieved.isoformat() if st.latest_retrieved else None,
},
}
)
# 整体健康信号
total_finished = sum(l["matches"]["finished"] for l in out_leagues)
total_stats = sum(l["stats"]["rows"] for l in out_leagues)
stats_coverage = round(total_stats / total_finished * 100, 1) if total_finished else 0.0
issues: list[str] = []
for l in out_leagues:
if l["matches"]["finished"] == 0:
issues.append(f"{l['name']}: 无已完赛比赛,请先运行「比赛数据」采集")
elif l["stats"]["rows"] == 0:
issues.append(f"{l['name']}: 已完赛 {l['matches']['finished']} 场但无统计回填,请运行「统计回填」采集")
elif stats_coverage < 80:
issues.append(f"{l['name']}: 统计覆盖率仅 {stats_coverage}%,建议增量回填")
if l["standings"]["rows"] == 0:
issues.append(f"{l['name']}: 无积分榜数据,请运行「积分榜」采集")
if not issues:
issues.append("各联赛数据完整度良好")
return {
"generated_at": datetime.now(timezone.utc).isoformat(),
"leagues": out_leagues,
"totals": {
"finished_matches": total_finished,
"stats_rows": total_stats,
"stats_coverage_pct": stats_coverage,
},
"issues": issues,
}
# ── 数据质量检查 API ────────────────────────────────────────────
@router.get("/data-quality")
async def data_quality_checks(db: AsyncSession = Depends(get_db_read)):
"""数据质量检查结果(只读)。"""
# 最近的失败记录
failures = (
await db.execute(
select(IngestFailure)
.where(IngestFailure.status.in_(["pending", "retrying"]))
.order_by(IngestFailure.created_at.desc())
.limit(20)
)
).scalars().all()
# 最近的质量检查
checks = (
await db.execute(
select(DataQualityCheck)
.order_by(DataQualityCheck.checked_at.desc())
.limit(20)
)
).scalars().all()
return {
"failures": [
{
"id": f.id,
"source": f.source_system,
"entity_type": f.entity_type,
"source_record_id": f.source_record_id,
"error_type": f.error_type,
"error_detail": f.error_detail,
"retry_count": f.retry_count,
"status": f.status,
"created_at": f.created_at.isoformat() if f.created_at else None,
}
for f in failures
],
"checks": [
{
"id": c.id,
"check_name": c.check_name,
"entity_type": c.entity_type,
"passed": c.passed,
"severity": c.severity,
"detail": c.detail,
"checked_at": c.checked_at.isoformat() if c.checked_at else None,
}
for c in checks
],
}
@router.post("/data-quality/run")
async def run_data_quality_check(db: AsyncSession = Depends(get_db_read)):
"""手动触发一次数据质量检查。"""
checks = []
# 检查1: 已完赛但无统计的比赛
finished_no_stats = (
await db.execute(
select(func.count())
.select_from(Match)
.outerjoin(MatchStats, Match.id == MatchStats.match_id)
.where(Match.match_status == "finished")
.where(MatchStats.id.is_(None))
)
).scalar() or 0
checks.append(DataQualityCheck(
check_name="finished_without_stats",
entity_type="match",
actual_value=float(finished_no_stats),
passed=finished_no_stats == 0,
severity="warning" if finished_no_stats > 0 else "info",
detail={"message": f"{finished_no_stats} 场已完赛比赛缺少统计数据"},
))
# 检查2: 积分榜缺失的联赛
leagues_without_standings = (
await db.execute(
select(func.count())
.select_from(League)
.outerjoin(Standing, League.id == Standing.league_id)
.where(Standing.id.is_(None))
)
).scalar() or 0
checks.append(DataQualityCheck(
check_name="league_without_standings",
entity_type="league",
actual_value=float(leagues_without_standings),
passed=leagues_without_standings == 0,
severity="warning" if leagues_without_standings > 0 else "info",
detail={"message": f"{leagues_without_standings} 个联赛缺少积分榜"},
))
for c in checks:
db.add(c)
await db.commit()
return {"ok": True, "checks": [{"name": c.check_name, "passed": c.passed} for c in checks]}
# ── 近似重名候选(只读,启发式,不做自动合并) ──────────────────────
@router.get("/team-name-duplicates")
async def team_name_duplicates(db: AsyncSession = Depends(get_db_read)):
"""只读列出近似重名候选(大小写变体/子串包含/前缀碰撞)。
启发式规则(命中任一即列为候选):
- 大小写变体: lower(name) 相同但 name 不同
- 子串包含: A 是 B 的子串且 len(A) ≥ 5
- 前缀碰撞: 前 8 字符相同(忽略大小写)
仅作排查参考,合并需走人工 SQL(见 docs/05-data.md)。
"""
teams = (await db.execute(select(Team.id, Team.name))).all()
by_lower: dict[str, list[dict]] = {}
for t in teams:
key = (t.name or "").lower()
by_lower.setdefault(key, []).append({"id": t.id, "name": t.name})
groups: list[dict] = []
# 规则1: 大小写变体(lower 相同但原名不同)
for key, members in by_lower.items():
if len(members) > 1:
groups.append({
"rule": "case_variant",
"key": key,
"members": members,
})
# 规则2 & 3: 子串包含 / 前缀碰撞(仅在 lower 名不同的组间比较)
distinct = [m for members in by_lower.values() for m in members]
seen_pairs: set[tuple[int, int]] = set()
for i, a in enumerate(distinct):
na = (a["name"] or "").lower()
for b in distinct[i + 1:]:
nb = (b["name"] or "").lower()
if na == nb:
continue # 已被规则1覆盖
pair = (min(a["id"], b["id"]), max(a["id"], b["id"]))
if pair in seen_pairs:
continue
hit = None
if len(na) >= 5 and na in nb:
hit = "substring"
elif len(nb) >= 5 and nb in na:
hit = "substring"
elif len(na) >= 8 and len(nb) >= 8 and na[:8] == nb[:8]:
hit = "prefix"
if hit:
seen_pairs.add(pair)
groups.append({
"rule": hit,
"members": [a, b],
})
return {
"count": len(groups),
"hint": "命中任一启发式仅表示'可疑',合并前请人工确认是否同一球队",
"groups": groups,
}
+23 -433
View File
@@ -1,439 +1,29 @@
"""后台管理路由:数据源配置的查看、修改与连通性测试
"""后台管理路由聚合入口:按职责拆分为四个子模块,统一挂载
所有接口需管理员鉴权(require_admin)配置项白名单见
src/core/runtime_config.py SETTING_DEFS,之外的 key 一律拒绝
所有路由仍挂在 /api/v1/admin,且均带 dependencies=[Depends(require_admin)]
(鉴权由各子路由器声明,行为与拆分前完全一致)
子模块:
- admin_datasources 数据源列表/连通性测试KeyRing采集健康概览
- admin_config settings CRUD运行日志
- admin_llm LLM agents/models/ping
- admin_quality statsdata-completenessdata-quality
"""
from __future__ import annotations
import logging
import time
from datetime import date, datetime, timedelta, timezone
from fastapi import APIRouter
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import func, select
import httpx
from src.api.routes.admin_config import router as admin_config_router
from src.api.routes.admin_datasources import router as admin_datasources_router
from src.api.routes.admin_ingest_jobs import router as admin_ingest_jobs_router
from src.api.routes.admin_llm import router as admin_llm_router
from src.api.routes.admin_quality import router as admin_quality_router
from src.api.routes.admin_teams import router as admin_teams_router
from src.api.deps import require_admin
from src.core.config import settings
from src.core.http_client import get_client
from src.core.log_buffer import get_entries
from src.core.runtime_config import (
AGENT_META,
SETTING_DEFS,
clear_runtime_value,
get_runtime_value,
get_setting_origin,
mask_value,
set_runtime_value,
)
from src.db.base import AsyncSession, get_db_read
from src.db.models import Injury, Match, MatchStats
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/admin", tags=["admin"], dependencies=[Depends(require_admin)])
# ── 数据源元数据 ────────────────────────────────────────────────
_SOURCES: list[dict] = [
{
"name": "bzzoiro",
"label": "Bzzoiro",
"description": "历史赛程与比分数据,覆盖全球主要联赛",
"setting_keys": ["BZZOIRO_KEY", "BZZOIRO_BASE"],
},
{
"name": "understat",
"label": "Understat",
"description": "xG(预期进球)进阶数据,无需 API Key,网页抓取",
"setting_keys": [],
},
{
"name": "injuries",
"label": "Injuries (API-Football)",
"description": "球员伤停信息,用于预测时考虑阵容完整性",
"setting_keys": ["API_FOOTBALL_KEY"],
},
]
class SettingUpdateIn(BaseModel):
value: str
async def _last_ingestion(db: AsyncSession, source: str) -> datetime | None:
"""各源最近一次采集时间(取自数据血缘字段,无记录返回 None)。"""
if source == "injuries":
return (await db.execute(select(func.max(Injury.retrieved_at)))).scalar()
return (
await db.execute(
select(func.max(MatchStats.retrieved_at)).where(MatchStats.source == source)
)
).scalar()
@router.get("/datasources")
async def list_datasources(db: AsyncSession = Depends(get_db_read)):
"""数据源列表:各配置项的脱敏值、来源(db/env/none)与最近采集时间。"""
result = []
for src in _SOURCES:
settings_out = []
for key in src["setting_keys"]:
origin, value = await get_setting_origin(key)
defn = SETTING_DEFS[key]
settings_out.append(
{
"key": key,
"label": defn.label,
"description": defn.description,
"sensitive": defn.sensitive,
"configured": origin != "none",
"masked": mask_value(value, defn.sensitive),
"origin": origin,
}
)
key_configured = all(s["configured"] for s in settings_out) if settings_out else True
last = await _last_ingestion(db, src["name"])
result.append(
{
"name": src["name"],
"label": src["label"],
"description": src["description"],
"key_configured": key_configured,
"last_ingestion": last.isoformat() if last else None,
"settings": settings_out,
}
)
return result
@router.get("/settings")
async def list_settings():
"""全部可配置项(脱敏),供后台各配置页渲染。"""
out = []
for key, defn in SETTING_DEFS.items():
origin, value = await get_setting_origin(key)
out.append(
{
"key": key,
"label": defn.label,
"description": defn.description,
"sensitive": defn.sensitive,
"configured": origin != "none",
"masked": mask_value(value, defn.sensitive),
"origin": origin,
}
)
return out
# ── LLM 可用模型检测 ────────────────────────────────────────────
@router.get("/logs")
async def read_logs(
level: str | None = Query(None, description="最低级别: DEBUG/INFO/WARNING/ERROR"),
keyword: str | None = Query(None, description="消息或 logger 关键字"),
limit: int = Query(200, ge=1, le=1000),
):
"""查询应用运行日志(内存环形缓冲,最新在前;进程重启后清零)。"""
entries = get_entries(level, keyword, limit)
return {"entries": entries, "count": len(entries)}
@router.get("/llm/agents")
async def list_llm_agents():
"""各专家/终裁的独立 LLM 配置状态(含当前生效模型的解析结果)。"""
out = []
for agent in AGENT_META:
aid = agent["id"].upper()
pfx = f"AGENT_{aid}_"
fields = {}
for suffix in ("MODEL", "BASE_URL", "API_KEY"):
origin, value = await get_setting_origin(f"{pfx}{suffix}")
defn = SETTING_DEFS[f"{pfx}{suffix}"]
fields[suffix.lower()] = {
"configured": origin != "none",
"masked": mask_value(value, defn.sensitive),
"origin": origin,
}
# 生效模型 = 覆盖 → 层级默认(专家/终裁 env) → 全局 LLM_MODEL
tier_default = (
settings.LLM_AGGREGATOR_MODEL if agent["id"] == "aggregator" else settings.LLM_SPECIALIST_MODEL
)
effective_model = (
fields["model"]["masked"]
if fields["model"]["configured"]
else (tier_default or await get_runtime_value("LLM_MODEL"))
)
out.append(
{
"id": agent["id"],
"label": agent["label"],
"fields": fields,
"effective_model": effective_model,
}
)
return out
@router.get("/llm/models")
async def list_llm_models():
"""探测当前 LLM 服务可用的模型列表(OpenAI 兼容 GET /models)。
只读探测,不产生费用;配置缺失或服务不可达时返回 ok=false 与原因
"""
base_url = (await get_runtime_value("LLM_BASE_URL")).rstrip("/")
api_key = await get_runtime_value("LLM_API_KEY")
if not base_url or not api_key:
return {"ok": False, "models": [], "detail": "LLM_BASE_URL 或 LLM_API_KEY 未配置"}
client = get_client()
start = time.monotonic()
try:
resp = await client.get(
f"{base_url}/models",
headers={"Authorization": f"Bearer {api_key}"},
timeout=httpx.Timeout(connect=10.0, read=20.0, write=10.0, pool=10.0),
)
except Exception as e:
return {
"ok": False,
"models": [],
"latency_ms": int((time.monotonic() - start) * 1000),
"detail": f"无法连接 LLM 服务: {e}",
}
latency = int((time.monotonic() - start) * 1000)
if resp.status_code in (401, 403):
return {"ok": False, "models": [], "latency_ms": latency, "detail": "密钥无效或无权限(HTTP 401/403)"}
if resp.status_code != 200:
return {"ok": False, "models": [], "latency_ms": latency, "detail": f"服务返回 HTTP {resp.status_code}"}
try:
data = resp.json()
except Exception:
return {"ok": False, "models": [], "latency_ms": latency, "detail": "响应不是合法 JSON"}
models: list[str] = []
items = data.get("data") if isinstance(data, dict) else None
if isinstance(items, list):
models = sorted(
str(m.get("id")) for m in items if isinstance(m, dict) and m.get("id")
)
if not models:
return {"ok": False, "models": [], "latency_ms": latency, "detail": "服务未返回模型列表"}
return {"ok": True, "models": models, "latency_ms": latency, "detail": f"{len(models)} 个可用模型"}
@router.put("/settings/{key}")
async def update_setting(key: str, body: SettingUpdateIn):
"""更新配置项(写入 app_settings 覆盖 .env)。传空值请改用 DELETE。"""
if key not in SETTING_DEFS:
raise HTTPException(404, f"不支持的配置项: {key}")
value = body.value.strip()
if not value:
raise HTTPException(400, "值不能为空;如需回落 .env 请调用清除接口")
await set_runtime_value(key, value)
defn = SETTING_DEFS[key]
return {"key": key, "masked": mask_value(value, defn.sensitive), "origin": "db"}
@router.delete("/settings/{key}")
async def clear_setting(key: str):
"""清除 DB 覆盖值,回落 .env 默认。"""
if key not in SETTING_DEFS:
raise HTTPException(404, f"不支持的配置项: {key}")
await clear_runtime_value(key)
origin, value = await get_setting_origin(key)
defn = SETTING_DEFS[key]
return {
"key": key,
"masked": mask_value(value, defn.sensitive),
"origin": origin,
}
# ── 连通性测试 ──────────────────────────────────────────────────
_TEST_TIMEOUT = 15
async def _probe(url: str, headers: dict | None = None, params: dict | None = None) -> dict:
"""单次 HTTP 探测,返回 (ok, status, latency_ms, detail)。不重试。"""
client = get_client()
start = time.monotonic()
try:
resp = await client.get(url, headers=headers, params=params, timeout=_TEST_TIMEOUT)
except Exception as e:
return {
"ok": False,
"status": None,
"latency_ms": int((time.monotonic() - start) * 1000),
"detail": f"无法连接: {e}",
}
latency = int((time.monotonic() - start) * 1000)
status = resp.status_code
if status == 200:
detail = "连接成功"
elif status in (401, 403):
detail = "服务可达,但密钥无效或无权限"
else:
detail = f"服务返回 HTTP {status}"
return {"ok": status == 200, "status": status, "latency_ms": latency, "detail": detail}
@router.post("/datasources/{name}/test")
async def test_datasource(name: str):
"""轻量连通性测试:真实请求上游一次,不触发任何入库。"""
src = next((s for s in _SOURCES if s["name"] == name), None)
if src is None:
raise HTTPException(404, f"未知数据源: {name}")
if name == "bzzoiro":
key = await get_runtime_value("BZZOIRO_KEY")
if not key:
return {"ok": False, "status": None, "latency_ms": 0, "detail": "BZZOIRO_KEY 未配置"}
base = (await get_runtime_value("BZZOIRO_BASE")).rstrip("/")
today = date.today().isoformat()
return await _probe(
f"{base}/events/",
headers={"Authorization": f"Token {key}", "Accept": "application/json"},
params={"date_from": today, "date_to": today},
)
if name == "understat":
return await _probe(
"https://understat.com/league/EPL/2025",
headers={"User-Agent": "Mozilla/5.0", "Accept": "text/html"},
)
# injuries (api-football)
api_key = await get_runtime_value("API_FOOTBALL_KEY")
if not api_key:
return {"ok": False, "status": None, "latency_ms": 0, "detail": "API_FOOTBALL_KEY 未配置"}
return await _probe(
"https://v3.football.api-sports.io/status",
headers={"x-apisports-key": api_key},
)
# ── 数据源健康/最近采集状态(只读,不触发采集) ──────────────────────
@router.get("/ingest/status")
async def ingest_status(db: AsyncSession = Depends(get_db_read)):
"""各数据源采集健康概览(只读,不触发任何采集)。
返回尽量可得的信息;基于现有表近似的数据会标明 approximation
失败追踪目前依赖系统日志缓冲,无专用采集失败表
"""
# ── bzzoiro: 落库目标是 matches 表,无专用采集时间戳 ──
# 近似:以 matches 表最大 match_date(已覆盖的最远比赛日) 与 created_at 作为参考
bzzoiro_key = await get_runtime_value("BZZOIRO_KEY")
bzzoiro_base = await get_runtime_value("BZZOIRO_BASE")
row = (
await db.execute(
select(
func.count().label("cnt"),
func.max(Match.match_date).label("latest_match_date"),
func.max(Match.created_at).label("latest_row_at"),
).where(Match.match_status == "finished")
)
).one()
bzzoiro = {
"name": "bzzoiro",
"label": "Bzzoiro",
"key_configured": bool(bzzoiro_key),
"base_url": (bzzoiro_base.rstrip("/") if bzzoiro_base else None) or settings.BZZOIRO_BASE,
"reachable": None, # 不主动探测
"last_success_at": row.latest_row_at.isoformat() if row.latest_row_at else None,
"latest_match_date": row.latest_match_date.isoformat() if row.latest_match_date else None,
"recent_count": row.cnt or 0,
"note": "approx:基于 matches.finished 表,last_success_at 为行写入时间而非精确采集完成时间",
"last_failure": _last_failure_log("bzzoiro"),
}
# ── understat: 落库到 match_stats(source=understat),有精确 retrieved_at ──
row = (
await db.execute(
select(
func.count().label("cnt"),
func.max(MatchStats.retrieved_at).label("latest_retrieved"),
).where(MatchStats.source == "understat")
)
).one()
understat = {
"name": "understat",
"label": "Understat",
"key_configured": True, # 无需 Key
"reachable": None,
"last_success_at": row.latest_retrieved.isoformat() if row.latest_retrieved else None,
"recent_count": row.cnt or 0,
"note": "基于 match_stats.source=understat 的 retrieved_at",
"last_failure": _last_failure_log("understat"),
}
# ── injuries: 落库到 injuries 表,有精确 retrieved_at;区分 Key/无数据/有数据 ──
api_key = await get_runtime_value("API_FOOTBALL_KEY")
row = (
await db.execute(
select(
func.count().label("cnt"),
func.max(Injury.retrieved_at).label("latest_retrieved"),
)
)
).one()
if not api_key:
injuries_status, injuries_note = "key_not_configured", "API_FOOTBALL_KEY 未配置"
elif not row.cnt:
injuries_status, injuries_note = "no_data", "本地无伤停数据,请先采集"
else:
injuries_status, injuries_note = "has_data", f"{row.cnt} 条伤停记录"
injuries = {
"name": "injuries",
"label": "Injuries (API-Football)",
"key_configured": bool(api_key),
"reachable": None,
"status": injuries_status,
"last_success_at": row.latest_retrieved.isoformat() if row.latest_retrieved else None,
"recent_count": row.cnt or 0,
"note": injuries_note,
"last_failure": _last_failure_log("injuries"),
}
return {"sources": [bzzoiro, understat, injuries]}
def _last_failure_log(source: str) -> dict | None:
"""从系统日志缓冲中查找某数据源的最近一次错误(仅作参考,非专用失败表)。"""
entries = get_entries(min_level="ERROR", keyword=source, limit=5)
if not entries:
return None
e = entries[0]
return {
"at": datetime.fromtimestamp(e["ts"]).isoformat(),
"logger": e["logger"],
"detail": e["message"][:200],
"note": "approx:来自内存日志缓冲,非专用采集失败表;进程重启后清零",
}
@router.get("/stats")
async def admin_stats(db: AsyncSession = Depends(get_db_read)):
"""管理区统计(只读):最近预测次数。轻量聚合,无 LLM 调用。"""
from sqlalchemy import func, text
from src.db.models import Prediction
day_ago = datetime.now(timezone.utc) - timedelta(days=1)
week_ago = datetime.now(timezone.utc) - timedelta(days=7)
r = (
await db.execute(
select(
func.count().label("total"),
func.count().filter(Prediction.created_at >= day_ago).label("last_24h"),
func.count().filter(Prediction.created_at >= week_ago).label("last_7d"),
)
)
).one()
return {"predictions": {"total": r.total, "last_24h": r.last_24h, "last_7d": r.last_7d}}
router = APIRouter()
router.include_router(admin_datasources_router)
router.include_router(admin_config_router)
router.include_router(admin_ingest_jobs_router)
router.include_router(admin_llm_router)
router.include_router(admin_quality_router)
router.include_router(admin_teams_router)
+60
View File
@@ -0,0 +1,60 @@
"""后台管理:球队别名管理(只读列表 + 添加别名)。
归一名(teams.name)是球队唯一键;别名(team_aliases)是同一球队的不同写法
(大小写/译名/缩写)到归一后 teams.id 的映射入库时 normalize(name) 依次查
teams.name team_aliases,命中即复用,避免重复 Team
不自动合并历史重复队;需显式添加别名(或先 SQL/再经由此接口)
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import desc, select
from src.api.deps import require_admin
from src.api.schemas import TeamAliasIn, TeamAliasOut
from src.db.base import AsyncSession, get_db_read
from src.db.models import Team, TeamAlias
from src.db.repositories import TeamRepository
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/admin", tags=["admin"], dependencies=[Depends(require_admin)])
@router.get("/teams/aliases", response_model=list[TeamAliasOut])
async def list_team_aliases(db: AsyncSession = Depends(get_db_read)):
"""列出所有球队别名(最新在前)。"""
rows = (await db.execute(select(TeamAlias).order_by(desc(TeamAlias.created_at)).limit(200))).scalars().all()
return [
TeamAliasOut(
alias_normalized=r.alias_normalized,
team_id=r.team_id,
original_alias=r.original_alias,
)
for r in rows
]
@router.post("/teams/aliases", response_model=TeamAliasOut, status_code=201)
async def add_team_alias(req: TeamAliasIn, db: AsyncSession = Depends(get_db_read)):
"""为已有 Team 添加别名(幂等:重复添加会更新指向)。
不自动合并历史重复队若需合并 AB:先为 A 的归一名添加别名指向 B,
再人工确认 A 是否仍有独立引用
"""
# 校验目标 Team 存在
team = await db.get(Team, req.team_id)
if team is None:
raise HTTPException(404, f"目标 Team 不存在: id={req.team_id}")
repo = TeamRepository(db)
row = await repo.add_alias(req.alias, req.team_id)
logger.info("添加 Team 别名: %s -> team_id=%s", req.alias, req.team_id)
return TeamAliasOut(
alias_normalized=row.alias_normalized,
team_id=row.team_id,
original_alias=row.original_alias,
)
+3
View File
@@ -83,6 +83,8 @@ async def login(body: LoginIn, request: Request, response: Response):
raise HTTPException(status_code=401, detail="密码错误")
_fail_times.pop(ip, None)
# Code Review High-4: 生产环境(HHTTPS)下 Cookie 必须带 Secure,防中间人窃取
secure = settings.APP_ENV == "production"
response.set_cookie(
key=SESSION_COOKIE,
value=create_session_token(await get_session_secret()),
@@ -90,6 +92,7 @@ async def login(body: LoginIn, request: Request, response: Response):
httponly=True,
samesite="lax",
path="/",
secure=secure,
)
logger.info("管理员登录成功 (ip=%s)", ip)
return {"ok": True, "expires_in_hours": settings.ADMIN_SESSION_TTL_HOURS}
+123 -85
View File
@@ -1,16 +1,26 @@
"""采集路由。"""
"""采集路由(bzzoiro 单一数据源)
任务类型:
events 比赛日程/比分(/events/)
standings 联赛积分榜(/leagues/{id}/standings/)
stats 已完赛比赛详细统计回填(/events/{id}/stats/)
all 依次执行以上三项
"""
from __future__ import annotations
import asyncio
import logging
import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from src.api.deps import require_admin
from src.api.schemas import IngestBzzoiroRequest, IngestResponse, IngestUnderstatRequest, IngestInjuriesRequest, IngestSimpleResponse
from src.data.config import BZZOIRO_LEAGUE_IDS, FDCO_TO_UNDERSTAT
from src.api.schemas import IngestBzzoiroRequest, IngestBzzoiroResponse
from src.data.config import BZZOIRO_LEAGUE_IDS
from src.data.bzzoiro_standings import ingest_bzzoiro_standings
from src.data.bzzoiro_stats import ingest_bzzoiro_event_stats
from src.data.sources import get_source
from src.data.injuries import ingest_injuries
from src.db.unit_of_work import get_uow
logger = logging.getLogger(__name__)
@@ -20,6 +30,8 @@ router = APIRouter(prefix="/api/v1", tags=["ingest"])
# 后台采集任务注册表:持强引用防止被 GC
_background_tasks: set[asyncio.Task] = set()
VALID_TASKS = {"events", "standings", "stats", "all"}
def _spawn(coro) -> None:
"""启动后台采集任务;异常已在任务内记录到系统日志。"""
@@ -28,98 +40,124 @@ def _spawn(coro) -> None:
task.add_done_callback(_background_tasks.discard)
@router.post("/ingest/bzzoiro", dependencies=[Depends(require_admin)])
@router.post("/ingest/bzzoiro", response_model=IngestBzzoiroResponse, dependencies=[Depends(require_admin)])
async def ingest_bzzoiro_route(req: IngestBzzoiroRequest):
"""触发 bzzoiro 采集。"""
# 未指定联赛 = 采集全部已知联赛;未指定状态 = 已完赛 + 未开赛都采集
"""触发 bzzoiro 采集(events / standings / stats / all)
启动后台任务前写入 ingest_jobs(pending),响应返回 job_id 供前端轮询
兼容原 message 字段(仍返回)
"""
if req.task not in VALID_TASKS:
raise HTTPException(status_code=422, detail=f"未知任务类型: {req.task}(可选: {', '.join(sorted(VALID_TASKS))})")
leagues = req.leagues or list(BZZOIRO_LEAGUE_IDS.keys())
statuses = [req.status] if req.status else ["finished", "scheduled"]
_spawn(_run_bzzoiro(leagues, req.date_from, req.date_to, statuses))
return {
"ok": True,
"message": f"采集任务已启动(后台执行,状态: {', '.join(statuses)}),请在「系统日志」查看进度与结果",
task_label = {"events": "比赛数据", "standings": "积分榜", "stats": "统计回填", "all": "全量(比赛+积分榜+统计)"}[req.task]
job_id = await _create_ingest_job(req.task, leagues, req)
_spawn(_run_bzzoiro(job_id, req.task, leagues, req))
return IngestBzzoiroResponse(
ok=True,
job_id=job_id,
message=f"采集任务已启动(后台执行,任务: {task_label}),请到「数据采集」页跟踪进度",
)
async def _create_ingest_job(task: str, leagues: list[str], req: IngestBzzoiroRequest) -> str:
"""写入一条 ingest_jobs(pending),返回 job_id。"""
from src.db.models import IngestJob
job_id = str(uuid.uuid4())
params = {
"leagues": leagues,
"date_from": req.date_from,
"date_to": req.date_to,
"status": req.status,
"task": task,
"limit": req.limit,
"season": req.season,
}
async with get_uow() as session:
job = IngestJob(id=job_id, task=task, params=params, status="pending")
session.add(job)
logger.info("ingest_jobs 创建: job=%s task=%s leagues=%s", job_id, task, leagues)
return job_id
async def _run_bzzoiro(leagues: list[str], date_from: str | None, date_to: str | None, statuses: list[str]) -> None:
"""后台执行 bzzoiro 采集:上游限速时单次可能耗时数分钟,必须脱离请求生命周期。"""
async def _run_bzzoiro(job_id: str, task: str, leagues: list[str], req: IngestBzzoiroRequest) -> None:
"""后台执行 bzzoiro 采集:上游限速时单次可能耗时数分钟,必须脱离请求生命周期。
状态流转: pending running (success|failed)
"""
from src.db.models import IngestJob
await _update_job(job_id, status="running", started_at=datetime.now(timezone.utc))
result: dict = {}
try:
source = get_source("bzzoiro")
merged: dict = {"leagues": {}, "total_inserted": 0, "total_updated": 0, "errors": []}
async with get_uow() as session:
for st in statuses:
r = await source.ingest(
session,
leagues=leagues,
date_from=date_from,
date_to=date_to,
status=st,
if task in ("events", "all"):
statuses = [req.status] if req.status else ["finished", "scheduled"]
source = get_source("bzzoiro")
merged: dict = {"leagues": {}, "total_inserted": 0, "total_updated": 0, "errors": []}
# D2 修复: 按联赛分批提交,避免超长事务
for code in leagues:
for st in statuses:
async with get_uow() as session:
r = await source.ingest(
session, leagues=[code],
date_from=req.date_from, date_to=req.date_to, status=st,
)
merged["total_inserted"] += r.get("total_inserted", 0)
merged["total_updated"] += r.get("total_updated", 0)
merged["errors"].extend(r.get("errors", []))
acc = merged["leagues"].setdefault(code, {"inserted": 0, "updated": 0, "errors": []})
acc["inserted"] += r.get("inserted", 0)
acc["updated"] += r.get("updated", 0)
acc["errors"].extend(r.get("errors", []))
logger.info(
"bzzoiro 比赛采集完成: 新增 %d, 更新 %d, 联赛 %d 个, 状态 %s",
merged["total_inserted"], merged["total_updated"], len(merged["leagues"]), statuses,
)
if merged["errors"]:
logger.warning("bzzoiro 比赛采集错误 %d 条: %s", len(merged["errors"]), merged["errors"][:3])
result["events"] = merged
if task in ("standings", "all"):
async with get_uow() as session:
r = await ingest_bzzoiro_standings(session, leagues=leagues, season=req.season)
if r["errors"]:
logger.warning("bzzoiro 积分榜采集部分失败: %s", r["errors"][:3])
else:
logger.info("bzzoiro 积分榜采集完成: upsert %d", r["total_upserted"])
result["standings"] = r
if task in ("stats", "all"):
async with get_uow() as session:
r = await ingest_bzzoiro_event_stats(
session, leagues=leagues, limit=req.limit, only_missing=True
)
merged["total_inserted"] += r.get("total_inserted", 0)
merged["total_updated"] += r.get("total_updated", 0)
merged["errors"].extend(r.get("errors", []))
for code, stat in r.get("leagues", {}).items():
acc = merged["leagues"].setdefault(code, {"inserted": 0, "updated": 0, "errors": []})
acc["inserted"] += stat.get("inserted", 0)
acc["updated"] += stat.get("updated", 0)
acc["errors"].extend(stat.get("errors", []))
league_errors = {c: stat["errors"] for c, stat in merged["leagues"].items() if stat.get("errors")}
logger.info(
"bzzoiro 采集完成: 新增 %d, 更新 %d, 联赛 %d 个, 状态 %s",
merged["total_inserted"], merged["total_updated"], len(merged["leagues"]), statuses,
if r["errors"]:
logger.warning("bzzoiro 统计回填错误 %d 条: %s", len(r["errors"]), r["errors"][:3])
result["stats"] = r
await _update_job(job_id, status="success", result=result, finished_at=datetime.now(timezone.utc))
logger.info("ingest_jobs 完成: job=%s task=%s", job_id, task)
except Exception as e:
logger.exception("bzzoiro 采集任务失败(task=%s)", task)
await _update_job(
job_id, status="failed", error=str(e), finished_at=datetime.now(timezone.utc),
)
if league_errors:
sample = {c: errs[:1] for c, errs in list(league_errors.items())[:3]}
logger.warning("bzzoiro 部分联赛存在错误: %s", sample)
if merged["errors"]:
logger.warning("bzzoiro 采集错误 %d 条: %s", len(merged["errors"]), merged["errors"][:3])
logger.debug("bzzoiro 采集明细: leagues=%s", list(merged["leagues"].keys()))
except Exception:
logger.exception("bzzoiro 采集任务失败")
@router.post("/ingest/understat", dependencies=[Depends(require_admin)])
async def ingest_understat_route(req: IngestUnderstatRequest):
"""触发 understat xG 回填。"""
leagues_to_run = [req.league] if req.league else list(FDCO_TO_UNDERSTAT.keys())
_spawn(_run_understat(leagues_to_run, req.season))
return {"ok": True, "message": "xG 回填任务已启动(后台执行),请在「系统日志」查看结果"}
async def _update_job(job_id: str, **fields) -> None:
"""更新 ingest_jobs 单行;失败仅记日志,绝不抛异常(避免干扰采集主流程)。"""
from src.db.models import IngestJob
async def _run_understat(leagues_to_run: list[str], season: int) -> None:
try:
source = get_source("understat")
merged: dict = {"count": 0, "updated": 0, "skipped": 0, "unmatched": 0, "errors": []}
async with get_uow() as session:
for league in leagues_to_run:
r = await source.ingest(session, league=league, season=season)
for k in ("count", "updated", "skipped", "unmatched"):
merged[k] += r.get(k, 0)
merged["errors"].extend(r.get("errors", []))
logger.info(
"understat 回填完成: 联赛 %d 个, 更新 %d, 未匹配 %d, 错误 %d",
len(leagues_to_run), merged["updated"], merged["unmatched"], len(merged["errors"]),
)
except Exception:
logger.exception("understat 回填任务失败")
@router.post("/ingest/injuries", dependencies=[Depends(require_admin)])
async def ingest_injuries_route(req: IngestInjuriesRequest):
"""触发伤停采集。"""
_spawn(_run_injuries(req.date))
return {"ok": True, "message": "伤停采集任务已启动(后台执行),请在「系统日志」查看结果"}
async def _run_injuries(date: str | None) -> None:
try:
async with get_uow() as session:
result = await ingest_injuries(session, date=date)
logger.info(
"injuries 采集完成: 新增 %d, 更新 %d, 错误 %d",
result.get("count", 0), result.get("updated", 0), len(result.get("errors", [])),
)
if result.get("errors"):
logger.warning("injuries 采集错误: %s", result["errors"][:3])
job = await session.get(IngestJob, job_id)
if job is None:
logger.warning("ingest_jobs 更新失败: job=%s 不存在", job_id)
return
for k, v in fields.items():
setattr(job, k, v)
except Exception:
logger.exception("injuries 采集任务失败")
logger.warning("ingest_jobs 更新异常: job=%s fields=%s", job_id, list(fields.keys()))
+135 -9
View File
@@ -4,19 +4,36 @@ from __future__ import annotations
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import or_, select
from sqlalchemy.orm import selectinload
from sqlalchemy import func, or_, select
from sqlalchemy.orm import load_only, selectinload
from src.api.deps import require_admin
from src.api.schemas import MatchListOut, MatchOut, PredictionOut
from src.db.base import AsyncSession, get_db_read
from src.db.models import League, Match, Prediction
from src.db.models import League, Match, MatchStats, Prediction, Standing, Team
router = APIRouter(prefix="/api/v1", tags=["data"])
@router.get("/leagues", response_model=list[dict], dependencies=[Depends(require_admin)])
def _stats_dict(stats) -> dict | None:
"""把 MatchStats ORM 对象序列化为前端可读的扁平 dict。"""
if stats is None:
return None
return {
"home_xg": stats.home_xg, "away_xg": stats.away_xg,
"home_shots": stats.home_shots, "away_shots": stats.away_shots,
"home_shots_on_target": stats.home_shots_on_target, "away_shots_on_target": stats.away_shots_on_target,
"home_corners": stats.home_corners, "away_corners": stats.away_corners,
"home_possession": stats.home_possession,
"home_yellow_cards": stats.home_yellow_cards, "away_yellow_cards": stats.away_yellow_cards,
"home_red_cards": stats.home_red_cards, "away_red_cards": stats.away_red_cards,
"home_big_chances": stats.home_big_chances, "away_big_chances": stats.away_big_chances,
"home_fouls": stats.home_fouls, "away_fouls": stats.away_fouls,
}
@router.get("/leagues", response_model=list[dict])
async def list_leagues(db: AsyncSession = Depends(get_db_read)):
"""联赛列表(公开只读,P1-3: 公开站联赛筛选需要;仅返回展示字段)。"""
stmt = select(League).order_by(League.name)
result = await db.execute(stmt)
leagues = result.scalars().all()
@@ -32,8 +49,20 @@ async def list_matches(
limit: int = Query(50, ge=1, le=100),
db: AsyncSession = Depends(get_db_read),
):
"""比赛列表(游标分页)。"""
q = select(Match).options(selectinload(Match.league), selectinload(Match.home_team), selectinload(Match.away_team))
"""比赛列表(游标分页)。
加载策略(列表 vs 详情):
- 列表: selectinload 序列化需要的 3 个关系 + stats,且用 load_only 限定列
(League.code / Team.name,name_zh / MatchStats.home_xg,away_xg),避免传输全列;
同时一次性加载 stats 消除 N+1(m.stats.home_xg 此前触发懒加载)
- 详情(/matches/{id}):保持完整 options(league/teams/stats 全列 + 最近预测)
"""
q = select(Match).options(
selectinload(Match.league).load_only(League.code),
selectinload(Match.home_team).load_only(Team.name, Team.name_zh),
selectinload(Match.away_team).load_only(Team.name, Team.name_zh),
selectinload(Match.stats).load_only(MatchStats.home_xg, MatchStats.away_xg),
)
if cursor:
try:
@@ -155,6 +184,7 @@ async def get_match(match_id: int, db: AsyncSession = Depends(get_db_read)):
match_stage=m.match_stage,
home_xg=m.stats.home_xg if m.stats else None,
away_xg=m.stats.away_xg if m.stats else None,
stats=_stats_dict(m.stats) if m.stats else None,
recent_predictions=[
PredictionOut(
id=p.id, match_id=p.match_id, provider=p.provider, model=p.model,
@@ -172,9 +202,9 @@ async def get_match(match_id: int, db: AsyncSession = Depends(get_db_read)):
)
@router.get("/matches/{match_id}/context", dependencies=[Depends(require_admin)])
@router.get("/matches/{match_id}/context")
async def match_context(match_id: int, db: AsyncSession = Depends(get_db_read)):
"""比赛上下文(只读,不触发 LLM):双方近况 + 历史交锋。
"""比赛上下文(公开只读,P1-2: 公开站详情页需要;不触发 LLM):双方近况 + 历史交锋。
全部基于现有数据聚合:
- recent_home / recent客队:该队最近 5 场已完赛(进球/结果)
@@ -246,3 +276,99 @@ async def match_context(match_id: int, db: AsyncSession = Depends(get_db_read)):
"away_recent": [_row_to_dict(r) for r in away_recent],
"h2h": [_row_to_dict(r) for r in h2h],
}
@router.get("/standings")
async def list_standings(
league: str | None = Query(None, description="联赛代码,如 E0;空 = 全部联赛"),
season: str | None = Query(None, description="赛季标签,如 2026-2027;空 = 各联赛最新赛季"),
db: AsyncSession = Depends(get_db_read),
):
"""联赛积分榜(只读)。按联赛分组,每张榜按 position 排序。
P0-02: standings 为追加快照,公开接口取每队 available_at 最新快照
(league_id, season, team_id 上按 available_at 取最新)
season 为空时返回每个联赛最新采集到的赛季榜单
"""
# 取每个联赛最新赛季(当 season 为空时)
latest_seasons: dict[int, str] = {}
if season is None:
rows = (
await db.execute(
select(Standing.league_id, func.max(Standing.season).label("latest"))
.group_by(Standing.league_id)
)
).all()
latest_seasons = {r.league_id: r.latest for r in rows}
# 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 = (
select(Standing, League)
.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())
)
if league:
q = q.where(League.code == league)
if season:
q = q.where(Standing.season == season)
else:
# 多联赛时只保留各联赛最新赛季
if latest_seasons:
q = q.where(
or_(
*(
(Standing.league_id == lid) & (Standing.season == ls)
for lid, ls in latest_seasons.items()
)
)
)
rows = (await db.execute(q)).all()
# 按联赛分组
grouped: dict[str, dict] = {}
for standing, lg in rows:
key = lg.code
if key not in grouped:
grouped[key] = {
"league_code": lg.code,
"league_name": lg.name,
"season": standing.season,
"retrieved_at": standing.retrieved_at.isoformat() if standing.retrieved_at else None,
"rows": [],
}
grouped[key]["rows"].append(
{
"position": standing.position,
"team": standing.team.name_zh or standing.team.name if standing.team else "?",
"team_en": standing.team.name if standing.team else "?",
"played": standing.played,
"won": standing.won,
"drawn": standing.drawn,
"lost": standing.lost,
"goals_for": standing.goals_for,
"goals_against": standing.goals_against,
"goal_diff": standing.goal_diff,
"points": standing.points,
"xg_for": standing.xg_for,
"xg_against": standing.xg_against,
"form": standing.form,
"zone": standing.zone,
}
)
return {"leagues": list(grouped.values())}
+42 -57
View File
@@ -63,77 +63,42 @@ async def predict(req: PredictRequest, request: Request):
logger.exception("predict unexpected error")
raise HTTPException(500, "预测失败,请查看服务器日志")
# baseline 模式:结果已是 dict,需独立落库(prediction_id)
if req.mode == "baseline":
prediction_id = await _persist_baseline(req.match_id, result)
else:
prediction_id = result.prediction_id
# D2: 三种模式统一返回 PredictResult —— 字段映射单一化,无 dict 分支。
# P3-2:baseline 已在服务层(predict_baseline)落库并回填真实 prediction_id,
# 路由层不再需要特殊的 _persist_baseline,与 single/multi 路径统一。
prediction_id = result.prediction_id
# 3. 结果映射(无 DB 访问)
logger.info(
"预测完成 match=%s mode=%s pred=%s:%s (%s)",
req.match_id, req.mode,
result.get("pred_home_goals") if isinstance(result, dict) else result.pred_home_goals,
result.get("pred_away_goals") if isinstance(result, dict) else result.pred_away_goals,
result.get("pred_1x2") if isinstance(result, dict) else result.pred_1x2,
result.pred_home_goals, result.pred_away_goals, result.pred_1x2,
)
result_dict = result if isinstance(result, dict) else None
return PredictOut(
prediction_id=prediction_id,
provider=result.get("provider") if result_dict else result.provider,
model=result.get("model") if result_dict else result.model,
prompt_version=result.get("prompt_version") if result_dict else getattr(result, "prompt_version", None),
provider=result.provider,
model=result.model,
prompt_version=result.prompt_version,
mode=req.mode,
pred_home_goals=result.get("pred_home_goals") if result_dict else result.pred_home_goals,
pred_away_goals=result.get("pred_away_goals") if result_dict else result.pred_away_goals,
alt_pred_home_goals=result.get("alt_pred_home_goals") if result_dict else result.alt_pred_home_goals,
alt_pred_away_goals=result.get("alt_pred_away_goals") if result_dict else result.alt_pred_away_goals,
pred_1x2=result.get("pred_1x2") if result_dict else result.pred_1x2,
subjective_confidence=result.get("subjective_confidence") if result_dict else result.subjective_confidence,
reasoning=result.get("reasoning") if result_dict else result.reasoning,
status=result.get("status", "success") if result_dict else getattr(result, "status", "success"),
agent_outputs=result.get("agent_outputs") if result_dict else getattr(result, "agent_outputs", None),
agent_weights=result.get("agent_weights") if result_dict else getattr(result, "agent_weights", None),
context=result.get("context", "") if result_dict else result.context,
latency_ms=result.get("latency_ms", 0) if result_dict else result.latency_ms,
prompt_tokens=result.get("prompt_tokens") if result_dict else getattr(result, "prompt_tokens", None),
completion_tokens=result.get("completion_tokens") if result_dict else getattr(result, "completion_tokens", None),
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),
)
async def _persist_baseline(match_id: int, baseline: dict) -> int:
"""将基线预测结果写入 prediction 表,复用 upsert 语义。"""
from src.db.unit_of_work import get_uow
from src.llm.predict import _upsert_prediction
async with get_uow() as session:
pred = await _upsert_prediction(
session,
match_id=match_id,
provider_name="baseline",
model="baseline",
mode="baseline",
run_type="baseline",
values={
"prompt_version": "baseline_v1",
"prompt_tokens": 0,
"completion_tokens": 0,
"latency_ms": 0,
"pred_home_goals": baseline["pred_home_goals"],
"pred_away_goals": baseline["pred_away_goals"],
"pred_1x2": baseline["pred_1x2"],
"subjective_confidence": baseline["subjective_confidence"],
"reasoning": baseline["reasoning"],
"raw_response": baseline.get("raw", baseline),
"status": "success",
},
)
return pred.id
@router.get("/predictions", response_model=list[PredictionOut], dependencies=[Depends(require_admin)])
async def list_predictions(
match_id: int | None = None,
@@ -167,11 +132,31 @@ async def list_predictions(
actual_home_goals=p.actual_home_goals,
actual_away_goals=p.actual_away_goals,
settled=p.settled,
match=_match_dict(p.match) if p.match else None,
)
for p in rows
]
def _match_dict(m) -> dict | None:
if m is None:
return None
return {
"id": m.id,
"league_code": m.league.code if m.league else None,
"season": m.season,
"home_team": m.home_team.name if m.home_team else "?",
"away_team": m.away_team.name if m.away_team else "?",
"home_team_zh": m.home_team.name_zh if m.home_team else None,
"away_team_zh": m.away_team.name_zh if m.away_team else None,
"match_date": m.match_date.isoformat() if m.match_date else None,
"match_status": m.match_status,
"home_goals": m.home_goals,
"away_goals": m.away_goals,
"match_stage": m.match_stage,
}
@router.get("/predictions/{prediction_id}", response_model=PredictionOut, dependencies=[Depends(require_admin)])
async def get_prediction(prediction_id: int, db: AsyncSession = Depends(get_db_read)):
p = await db.get(Prediction, prediction_id)
+185
View File
@@ -0,0 +1,185 @@
"""定时任务管理路由。"""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select, delete
from src.api.deps import require_admin
from src.api.schemas import ScheduleIn, ScheduleUpdate, ScheduleOut
from src.core.scheduler import scheduler
from src.data.bzzoiro_standings import ingest_bzzoiro_standings
from src.data.bzzoiro_stats import ingest_bzzoiro_event_stats
from src.data.sources import get_source
from src.data.config import BZZOIRO_LEAGUE_IDS
from src.db.base import AsyncSession, get_db_read
from src.db.models import Schedule
from src.db.unit_of_work import get_uow
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/admin", tags=["schedule"], dependencies=[Depends(require_admin)])
async def _run_scheduled_task(schedule_id: str) -> None:
"""执行定时任务的回调函数。"""
async with get_uow() as session:
stmt = select(Schedule).where(Schedule.id == schedule_id)
sched = (await session.execute(stmt)).scalar_one_or_none()
if sched is None or not sched.enabled:
return
leagues = sched.leagues.split(",") if sched.leagues else list(BZZOIRO_LEAGUE_IDS.keys())
task = sched.task
try:
if task in ("events", "all"):
statuses = ["finished", "scheduled"]
source = get_source("bzzoiro")
for st in statuses:
await source.ingest(session, leagues=leagues, status=st)
if task in ("standings", "all"):
await ingest_bzzoiro_standings(session, leagues=leagues)
if task in ("stats", "all"):
await ingest_bzzoiro_event_stats(session, leagues=leagues, limit=500, only_missing=True)
sched.last_status = "success"
except Exception:
logger.exception("定时任务执行失败: %s", schedule_id)
sched.last_status = "failed"
finally:
sched.last_run_at = datetime.now(timezone.utc)
def _sync_scheduler() -> None:
"""同步数据库中的调度配置到调度器。"""
# 这是一个简化版本:实际应该在 lifespan 中异步同步
pass
@router.get("/schedules")
async def list_schedules(db: AsyncSession = Depends(get_db_read)):
"""列出所有定时任务。"""
rows = (await db.execute(select(Schedule).order_by(Schedule.created_at))).scalars().all()
return [
ScheduleOut(
id=s.id,
task=s.task,
cron=s.cron,
leagues=s.leagues,
enabled=s.enabled,
last_run_at=s.last_run_at.isoformat() if s.last_run_at else None,
last_status=s.last_status,
)
for s in rows
]
@router.post("/schedules")
async def create_schedule(req: ScheduleIn, db: AsyncSession = Depends(get_db_read)):
"""创建定时任务。"""
sched = Schedule(
id=req.id,
task=req.task,
cron=req.cron,
leagues=",".join(req.leagues) if req.leagues else None,
enabled=req.enabled,
)
db.add(sched)
await db.commit()
# 注册到调度器(注意: 第 2 参是 cron 表达式,不是 task 类型名)
scheduler.register(req.id, req.cron, lambda: _run_scheduled_task(req.id), enabled=req.enabled)
return {"ok": True, "id": req.id}
@router.put("/schedules/{schedule_id}")
async def update_schedule(schedule_id: str, req: ScheduleUpdate, db: AsyncSession = Depends(get_db_read)):
"""更新定时任务(部分更新)。"""
stmt = select(Schedule).where(Schedule.id == schedule_id)
sched = (await db.execute(stmt)).scalar_one_or_none()
if sched is None:
raise HTTPException(404, "定时任务不存在")
if req.task is not None:
sched.task = req.task
if req.cron is not None:
sched.cron = req.cron
if req.leagues is not None:
sched.leagues = ",".join(req.leagues) if req.leagues else None
if req.enabled is not None:
sched.enabled = req.enabled
await db.commit()
# 更新调度器(第 2 参传 cron 表达式;使用最终值)
scheduler.register(schedule_id, sched.cron, lambda: _run_scheduled_task(schedule_id), enabled=sched.enabled)
return {"ok": True}
@router.delete("/schedules/{schedule_id}")
async def delete_schedule(schedule_id: str, db: AsyncSession = Depends(get_db_read)):
"""删除定时任务。"""
await db.execute(delete(Schedule).where(Schedule.id == schedule_id))
await db.commit()
scheduler.remove(schedule_id)
return {"ok": True}
@router.post("/schedules/{schedule_id}/run")
async def run_schedule_now(schedule_id: str):
"""手动触发定时任务。"""
import asyncio
asyncio.create_task(_run_scheduled_task(schedule_id))
return {"ok": True, "message": "任务已启动"}
# ── 采集失败重试 ────────────────────────────────────────────────
@router.get("/ingest-failures")
async def list_ingest_failures(db: AsyncSession = Depends(get_db_read)):
"""列出采集失败记录。"""
from src.db.models import IngestFailure
rows = (
await db.execute(
select(IngestFailure).order_by(IngestFailure.created_at.desc()).limit(50)
)
).scalars().all()
return [
{
"id": f.id,
"source": f.source_system,
"entity_type": f.entity_type,
"source_record_id": f.source_record_id,
"error_type": f.error_type,
"error_detail": f.error_detail,
"retry_count": f.retry_count,
"status": f.status,
"next_retry_at": f.next_retry_at.isoformat() if f.next_retry_at else None,
"created_at": f.created_at.isoformat() if f.created_at else None,
}
for f in rows
]
@router.post("/ingest-failures/{failure_id}/retry")
async def retry_ingest_failure(failure_id: int, db: AsyncSession = Depends(get_db_read)):
"""重试一次采集失败。"""
from src.db.models import IngestFailure
stmt = select(IngestFailure).where(IngestFailure.id == failure_id)
failure = (await db.execute(stmt)).scalar_one_or_none()
if failure is None:
raise HTTPException(404, "失败记录不存在")
failure.status = "retrying"
failure.retry_count += 1
await db.commit()
# 触发重试(简化版:仅标记状态,实际重试逻辑由调度器处理)
return {"ok": True, "message": f"已标记重试 (第 {failure.retry_count} 次)"}
+60 -23
View File
@@ -7,13 +7,6 @@ from typing import Any
from pydantic import BaseModel, Field
class LeagueOut(BaseModel):
id: int
code: str
name: str
country: str | None
class MatchOut(BaseModel):
id: int
league_code: str | None
@@ -29,6 +22,8 @@ class MatchOut(BaseModel):
match_stage: str | None
home_xg: float | None = None
away_xg: float | None = None
# 比赛详细统计(bzzoiro /events/{id}/stats/),无统计为 None
stats: dict | None = None
# 该场比赛的最近预测摘要(按时间倒序,最多 5 条;无预测为空)
recent_predictions: list[PredictionOut] = []
@@ -46,6 +41,7 @@ class PredictRequest(BaseModel):
prompt_version: str | None = None
mode: str = Field(
"multi",
pattern="^(multi|single|baseline)$",
description="multi(默认,5专家+终裁) | single(单次) | baseline(极简统计基线,不调用 LLM)",
)
use_cache: bool = True
@@ -100,6 +96,8 @@ class PredictionOut(BaseModel):
actual_home_goals: int | None
actual_away_goals: int | None
settled: bool
# 比赛信息(可选,列表接口不返回以减少 payload)
match: dict | None = None
class IngestBzzoiroRequest(BaseModel):
@@ -107,30 +105,69 @@ class IngestBzzoiroRequest(BaseModel):
date_from: str | None = None
date_to: str | None = None
status: str | None = Field(None, description="finished/scheduled;空 = 两者都采集")
task: str = Field("events", description="采集任务: events(比赛)/standings(积分榜)/stats(统计回填)/all")
limit: int = Field(100, ge=1, le=500, description="stats 回填单次最大比赛数")
season: str | None = Field(None, description="standings 赛季,如 '2026-2027';空 = 当前赛季")
class IngestResponse(BaseModel):
leagues: dict
total_inserted: int
total_updated: int
errors: list[str] = []
class TeamAliasIn(BaseModel):
"""POST /api/v1/admin/teams/aliases 请求体:为已有 Team 添加别名。"""
alias: str = Field(..., min_length=1, max_length=120, description="球队别名(原始写法)")
team_id: int = Field(..., gt=0, description="归一后的目标 teams.id")
class IngestUnderstatRequest(BaseModel):
league: str | None = Field(None, description="联赛代码,如 'E0';空 = 全部已知联赛")
season: int = Field(default_factory=lambda: date.today().year, description="赛季起始年,如 2025 表示 2025-2026 赛季")
class TeamAliasOut(BaseModel):
alias_normalized: str
team_id: int
original_alias: str
class IngestInjuriesRequest(BaseModel):
date: str | None = Field(None, description="日期 YYYY-MM-DD,为空则采集当天")
class IngestBzzoiroResponse(BaseModel):
"""POST /api/v1/ingest/bzzoiro 响应:兼容原 message 字段,新增 job_id 供轮询。"""
ok: bool = True
job_id: str = Field(..., description="采集任务 ID(GET /api/v1/admin/ingest/jobs/{job_id} 轮询)")
message: str = ""
class IngestSimpleResponse(BaseModel):
count: int = 0
updated: int = 0
skipped: int = 0
unmatched: int = 0
errors: list[str] = []
class IngestJobOut(BaseModel):
"""采集任务状态详情。"""
id: str
task: str
params: dict
status: str # pending | running | success | failed
result: dict | None = None
error: str | None = None
created_at: datetime | None = None
started_at: datetime | None = None
finished_at: datetime | None = None
class ScheduleIn(BaseModel):
id: str = Field(..., description="任务唯一标识,如 'daily-events'")
task: str = Field(..., description="events / standings / stats / all")
cron: str = Field(..., description="cron 表达式,如 '0 8 * * *' (每天 8 点)")
leagues: list[str] = Field(default_factory=list, description="联赛代码列表,空=全部")
enabled: bool = True
class ScheduleUpdate(BaseModel):
task: str | None = None
cron: str | None = None
leagues: list[str] | None = None
enabled: bool | None = None
class ScheduleOut(BaseModel):
id: str
task: str
cron: str
leagues: str | None
enabled: bool
last_run_at: str | None
last_status: str | None
class SettleRequest(BaseModel):
+10 -1
View File
@@ -11,6 +11,10 @@ class Settings(BaseSettings):
# --- app ---
APP_ENV: str = "development"
LOG_LEVEL: str = "INFO"
# P3-3:多 worker 时应用内限流与 KeyRing 各自独立计数(配额放大 N 倍)。
# 设为 True 时若以多 worker 启动 uvicorn 则拒绝启动,避免静默配额漂移。
# 仅在你已前置 Nginx/网关做全局限流、确认不需要此守护时留空/False。
STRICT_SINGLE_WORKER: bool = False
# 生产环境强制要求管理鉴权配置,即使 APP_ENV=production 也生效。
# True 时若 auth_configured() 为 False 则拒绝(503),development 保持 fail-open。
REQUIRE_ADMIN_AUTH: bool = False
@@ -28,10 +32,15 @@ class Settings(BaseSettings):
LLM_SPECIALIST_MODEL: str = ""
LLM_AGGREGATOR_MODEL: str = ""
# ── 预测缓存 ──
# 预测响应缓存后端:空(默认)=进程内 LRU+TTL 字典;填 redis://host:port/db 启用 Redis。
# Redis 失败自动降级内存缓存并 warning,不中断预测;不强制依赖 redis 包。
# TTL 固定 300s(5 分钟),键格式与内存后端一致(含 prompt 模板 hash)。
PREDICT_CACHE_URL: str = ""
# --- data sources ---
BZZOIRO_KEY: str = ""
BZZOIRO_BASE: str = "https://sports.bzzoiro.com/api/v2"
API_FOOTBALL_KEY: str = ""
# --- 代理头信任 ---
# 为 True 时才解析 X-Forwarded-For,否则只用 request.client.host。
+1 -3
View File
@@ -2,9 +2,7 @@
使用方:
- src/llm/provider.py: LLM 调用
- src/data/bzzoiro.py: bzzoiro 比赛数据
- src/data/understat.py: xG 抓取
- src/data/injuries.py: 伤停抓取
- src/data/bzzoiro.py: bzzoiro 比赛数据 / 积分榜 / 事件统计
生命周期由 FastAPI lifespan 管理(关闭时 aclose)
调用方可通过 `timeout` 参数覆盖 per-request 超时
+15 -6
View File
@@ -7,6 +7,7 @@ DB 读取失败时也回落环境变量,保证采集不因管理表故障而中
from __future__ import annotations
import logging
import re
import secrets
from dataclasses import dataclass
@@ -39,14 +40,13 @@ class SettingDef:
# 允许在后台查看/修改的配置项白名单(之外的 key 一律拒绝读写)
SETTING_DEFS: dict[str, SettingDef] = {
"BZZOIRO_KEY": SettingDef(
"BZZOIRO_KEY", "Bzzoiro API Key", "比赛赛程 / 比分数据源凭证", sensitive=True,
"BZZOIRO_KEY", "Bzzoiro API Key",
"比赛赛程/比分/积分榜/统计的数据源凭证。支持多 key 轮换:用逗号、分号或换行分隔多个 key,遇到限流(429)自动切换",
sensitive=True,
),
"BZZOIRO_BASE": SettingDef(
"BZZOIRO_BASE", "Bzzoiro API 地址", "Bzzoiro 接口基础地址", sensitive=False,
),
"API_FOOTBALL_KEY": SettingDef(
"API_FOOTBALL_KEY", "API-Football Key", "伤停数据源凭证(api-sports)", sensitive=True,
),
"LLM_API_KEY": SettingDef(
"LLM_API_KEY", "LLM API Key", "大模型服务凭证(OpenAI 兼容接口)", sensitive=True,
),
@@ -64,7 +64,7 @@ AGENT_META: list[dict] = [
{"id": "form", "label": "近期状态分析专家"},
{"id": "stats", "label": "攻防数据分析专家"},
{"id": "home_away", "label": "主客因素分析专家"},
{"id": "injuries", "label": "阵容完整性分析专家"},
{"id": "standings", "label": "联赛排名分析专家"},
{"id": "h2h", "label": "历史交锋分析专家"},
{"id": "aggregator", "label": "终裁分析专家"},
]
@@ -83,11 +83,20 @@ for _agent in AGENT_META:
def mask_value(value: str, sensitive: bool) -> str:
"""脱敏展示:敏感值只留末 4 位;非敏感值原样返回。"""
"""脱敏展示:敏感值只留末 4 位;非敏感值原样返回。
key(逗号/分号/换行分隔)时显示数量, "3 个 key(末段 …XXXX)"
"""
if not value:
return ""
if not sensitive:
return value
# 检测多 key
keys = [k.strip() for k in re.split(r"[,;\n]", value) if k.strip()]
if len(keys) > 1:
last = keys[-1]
tail = last[-4:] if len(last) >= 4 else last
return f"{len(keys)} 个 key(末段 …{tail})"
return f"****{value[-4:]}" if len(value) >= 8 else "****"
+252
View File
@@ -0,0 +1,252 @@
"""定时任务调度器:支持 cron 表达式触发采集任务。"""
from __future__ import annotations
import asyncio
import logging
from datetime import datetime, timedelta
from typing import Callable, Coroutine
from croniter import croniter
logger = logging.getLogger(__name__)
class ScheduledTask:
"""一个定时任务。"""
#: 运行循环的轮询粒度(秒)。同时决定运行期 cron/next_run 变更的生效延迟上限。
SLEEP_TICK: float = 1.0
def __init__(
self,
task_id: str,
cron: str,
fn: Callable[[], Coroutine],
enabled: bool = True,
) -> None:
self.task_id = task_id
self.cron = cron
self.fn = fn
self.enabled = enabled
self.last_run: datetime | None = None
self.next_run: datetime | None = None
self._task: asyncio.Task | None = None
# 构造时立即校验 cron:非法表达式直接报错,不留给 _run_loop 静默吞掉。
# (全量审查 C1: 传入任务类型字符串时 croniter 抛异常被吞 → 任务永不触发)
self._calc_next(raise_on_error=True)
def _calc_next(self, *, raise_on_error: bool = False) -> None:
"""计算下次运行时间。
raise_on_error=True 时非法 cron ValueError(构造期用);
否则仅告警并置 next_run=None(运行期容错)
"""
try:
# 必须调用 datetime.now():传方法对象本身会让 croniter 在
# (start_time or now) 的算术里抛 TypeError,导致 next_run 永远为 None。
self.next_run = croniter(self.cron, datetime.now()).get_next(datetime)
except Exception as e:
self.next_run = None
msg = (
f"任务 {self.task_id!r} 的 cron 表达式非法: {self.cron!r} ({e})。"
"注意: 此处应为 cron 表达式(如 '0 8 * * *'),不是任务类型名。"
)
if raise_on_error:
raise ValueError(msg) from e
logger.error(msg)
def update(self, cron: str | None = None, enabled: bool | None = None) -> None:
if cron is not None:
self.cron = cron
if enabled is not None:
self.enabled = enabled
self._calc_next()
async def _run_loop(self) -> None:
"""任务运行循环。
睡眠策略: 使用固定的短 tick(SLEEP_TICK )轮询 next_run,而不是
一次性 sleep next_run原因是运行期可通过 API 更新 cron / 手动
调整 next_run;若按 wait_seconds 长时间沉睡,变更最长要等
wait_seconds 才生效(实测可达 60s),表现为改了不生效
"""
while True:
try:
if not self.enabled or not self.next_run:
await asyncio.sleep(self.SLEEP_TICK)
self._calc_next()
continue
now = datetime.now()
wait_seconds = (self.next_run - now).total_seconds()
if wait_seconds > 0:
# 短 tick 轮询,保证 next_run/cron 变更能及时被感知
await asyncio.sleep(min(wait_seconds, self.SLEEP_TICK))
continue
# 执行任务:先推进 next_run 再执行,避免任务耗时导致重复触发
self.last_run = datetime.now()
self._calc_next()
try:
logger.info("定时任务触发: %s (cron=%s)", self.task_id, self.cron)
await self.fn()
logger.info("定时任务完成: %s", self.task_id)
except asyncio.CancelledError:
raise
except Exception:
# 单个任务失败不能终止循环,否则一次异常即永久停摆
logger.exception("定时任务失败: %s", self.task_id)
except asyncio.CancelledError:
raise
except Exception:
# 循环体自身的意外异常(如 _calc_next)也不能终止调度
logger.exception("调度循环异常: %s", self.task_id)
await asyncio.sleep(self.SLEEP_TICK)
class DataQualityScheduler:
"""数据质量检查调度器(独立于采集任务)。"""
def __init__(self) -> None:
self._task: asyncio.Task | None = None
self._running = False
async def start(self) -> None:
self._running = True
self._task = asyncio.create_task(self._run_loop())
logger.info("数据质量检查调度器已启动")
async def stop(self) -> None:
self._running = False
if self._task:
self._task.cancel()
async def _run_loop(self) -> None:
"""每小时执行一次数据质量检查。"""
while self._running:
try:
await self._run_checks()
except Exception:
logger.exception("数据质量检查失败")
await asyncio.sleep(3600) # 每小时
async def _run_checks(self) -> None:
"""执行数据质量检查并写入 DataQualityCheck 表。"""
from src.db.base import AsyncSessionLocal
from src.db.models import DataQualityCheck, Match, MatchStats, Standing, League
from sqlalchemy import func, select
async with AsyncSessionLocal() as db:
# 检查1: 已完赛但无统计的比赛数
finished_no_stats = (
await db.execute(
select(func.count())
.select_from(Match)
.outerjoin(MatchStats, Match.id == MatchStats.match_id)
.where(Match.match_status == "finished")
.where(MatchStats.id.is_(None))
)
).scalar() or 0
db.add(DataQualityCheck(
check_name="finished_without_stats",
entity_type="match",
actual_value=float(finished_no_stats),
passed=finished_no_stats == 0,
severity="warning" if finished_no_stats > 0 else "info",
detail={"message": f"{finished_no_stats} 场已完赛比赛缺少统计数据"},
))
# 检查2: 积分榜缺失的联赛数
leagues_without_standings = (
await db.execute(
select(func.count())
.select_from(League)
.outerjoin(Standing, League.id == Standing.league_id)
.where(Standing.id.is_(None))
)
).scalar() or 0
db.add(DataQualityCheck(
check_name="league_without_standings",
entity_type="league",
actual_value=float(leagues_without_standings),
passed=leagues_without_standings == 0,
severity="warning" if leagues_without_standings > 0 else "info",
detail={"message": f"{leagues_without_standings} 个联赛缺少积分榜"},
))
await db.commit()
logger.info("数据质量检查完成: stats=%d, standings=%d", finished_no_stats, leagues_without_standings)
# 全局单例
quality_scheduler = DataQualityScheduler()
class Scheduler:
"""全局定时任务调度器。"""
def __init__(self) -> None:
self._tasks: dict[str, ScheduledTask] = {}
self._running = False
def register(
self,
task_id: str,
cron: str,
fn: Callable[[], Coroutine],
enabled: bool = True,
) -> ScheduledTask:
"""注册(或更新)一个定时任务。
若调度器已 start,新任务会立即启动其运行循环
否则运行期通过 API 新建的任务永远不会被执行(全量审查 C1 缺陷 2)
"""
existing = self._tasks.get(task_id)
if existing is not None:
existing.update(cron=cron, enabled=enabled)
# 更新 cron 后重新校验:改坏了要立刻报错,而不是静默失活
existing._calc_next(raise_on_error=True)
existing.fn = fn
task = existing
else:
task = ScheduledTask(task_id, cron, fn, enabled)
self._tasks[task_id] = task
if self._running:
self._ensure_loop(task)
return task
def _ensure_loop(self, task: ScheduledTask) -> None:
"""为任务启动运行循环(幂等:已在运行则跳过)。"""
if task._task is None or task._task.done():
task._task = asyncio.create_task(task._run_loop())
def get(self, task_id: str) -> ScheduledTask | None:
return self._tasks.get(task_id)
def list_all(self) -> list[ScheduledTask]:
return list(self._tasks.values())
def remove(self, task_id: str) -> None:
task = self._tasks.pop(task_id, None)
if task is not None and task._task is not None:
task._task.cancel()
async def start(self) -> None:
self._running = True
for task in self._tasks.values():
self._ensure_loop(task)
logger.info("定时调度器已启动, 共 %d 个任务", len(self._tasks))
async def stop(self) -> None:
self._running = False
for task in self._tasks.values():
if task._task:
task._task.cancel()
self._tasks.clear()
# 全局单例
scheduler = Scheduler()
-4
View File
@@ -30,10 +30,6 @@ _MIN_SECRET_KEY_LEN = 16
_WEAK_DB_PATTERNS = ("football:football@", "admin:admin@", "password@", "123456@")
class SecurityCheckError(Exception):
"""生产环境安全校验失败。"""
async def _auth_configured() -> bool:
"""运行时鉴权是否已配置(含数据库密码哈希/.env 明文/API Key)。"""
if await get_admin_password_hash():
+58 -343
View File
@@ -1,351 +1,66 @@
"""Bzzoiro 数据源:抓取 + 入库。
"""Bzzoiro 数据源:抓取 + 入库(单一数据源)—— 聚合门面
迁移自旧项目 app/data/sources/bzzoiro/,改成 async + 简化入库
使用 Repository 模式进行数据访问,不直接控制事务
实现按管线拆分(单文件 多模块),本模块只做再导出,保持不变量:
1. sources._load_sources() 仍从本模块导入 BzzoiroSource(注册表入口不变);
2. 测试与脚本对 `bz.<名称>` monkeypatch 语义不变 抓取函数 / REQUEST_INTERVAL
仍经本门面解析可替换;Bronze 写入助手(_write_raw_event/_write_lineage/
_safe_write_ingest_failure)已改为管线模块直接 import pipeline_write,
测试需 patch `src.data.pipeline_write.*` 源模块
三条管线(各自模块):
1. events 比赛日程/比分(/events/), source_event_id 血缘 bzzoiro_events.py
2. standings 联赛积分榜快照(/leagues/{id}/standings/) bzzoiro_standings.py
3. stats 已完赛比赛详细统计回填(/events/{id}/stats/) bzzoiro_stats.py
共享基础:HTTP 抓取( key 轮换)与字段转换 bzzoiro_common.py;
Bronze 基础设施(RawEvent/IngestFailure/DataLineage) pipeline_write.py
(各管线模块直接 import pipeline_write,不再经本门面转发)
D4(工程债): Team/League/Match 的查找/创建经 Repository (src/db/repositories.py),
各管线不直接控制事务(commit/rollback 由调用方 UnitOfWork 控制, flush)
Standing/RawEvent/Lineage 等管线内私有读写仍不强行 Repository
"""
from __future__ import annotations
import asyncio
import json as _json
import logging
import random
from collections.abc import Iterable
from datetime import datetime, timedelta, timezone
# ── 配置常量(原文件即从 config 再导出,维持 bz.REQUEST_INTERVAL 等引用) ──
from src.data.config import ( # noqa: F401
BZZOIRO_LEAGUE_IDS,
LEAGUE_COUNTRIES,
LEAGUE_NAMES,
REQUEST_INTERVAL,
)
from src.data.key_ring import _mask # noqa: F401 (R1 测试引用 bz._mask)
from src.data.normalize import normalize_bzzoiro # noqa: F401
from sqlalchemy import select
# ── 共享原语:HTTP 抓取 + 宽松字段转换 ──
# NOTE: 管线模块同时从 bzzoiro_common 直接 import _fetch_json_async 等(经本处也转发)。
from src.data.bzzoiro_common import ( # noqa: F401
_fetch_json_async,
_match_key,
_to_date,
_to_float_or_none,
_to_int_or_none,
)
import httpx
# ── events 管线:BzzoiroSource(注册表入口)+ 抓取/入库 ──
from src.data.bzzoiro_events import ( # noqa: F401
BzzoiroSource,
_events_record_id,
_write_events_bronze,
fetch_bzzoiro_events,
)
from src.core.runtime_config import get_runtime_value
from src.core.http_client import get_client
from src.data.config import BZZOIRO_LEAGUE_IDS, LEAGUE_COUNTRIES, LEAGUE_NAMES, REQUEST_INTERVAL
from src.data.normalize import normalize_bzzoiro
from src.data.team_names_zh import zh_name
from src.data.sources import register
from src.db.models import League, Match, MatchStats, Team
# ── standings 管线 ──
from src.data.bzzoiro_standings import ( # noqa: F401
_season_label_from_dates,
_write_standings_bronze,
fetch_bzzoiro_standings,
ingest_bzzoiro_standings,
)
logger = logging.getLogger(__name__)
def _to_date(value):
"""把 datetime / date / str 统一成 `date`。"""
if value is None:
return None
if hasattr(value, "date") and callable(value.date):
return value.date()
return value
def _match_key(home_team_id: int, away_team_id: int, match_date) -> tuple[int, int, str]:
"""比赛去重键:(主队, 客队, 天级日期 ISO 字符串)。
统一在这里构造,避免"预加载时用 str(date)、写入时用 isoformat()"这类
隐式格式依赖 两者当前恰好相等,但一旦有人改动其一就会静默失配,
导致所有比赛被判为不存在而重复插入
"""
d = _to_date(match_date)
return (home_team_id, away_team_id, d.isoformat() if d is not None else "")
async def _fetch_json_async(path: str, params: dict | None = None, max_retries: int = 3) -> dict | list:
"""异步 HTTP(bzzoiro 使用 httpx,不再阻塞事件循环线程池)。"""
base = (await get_runtime_value("BZZOIRO_BASE")).rstrip("/")
url = f"{base}/{path.lstrip('/')}"
key = await get_runtime_value("BZZOIRO_KEY")
if not key:
raise RuntimeError("BZZOIRO_KEY 未设置")
headers = {
"Authorization": f"Token {key}",
"Accept": "application/json",
}
last_exc: Exception | None = None
for attempt in range(max_retries):
try:
client = get_client()
# 整请求兜底: httpx 无 total 超时,用 wait_for 防「滴水式」限速挂死
resp = await asyncio.wait_for(
client.get(
url, headers=headers, params=params,
timeout=httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0),
),
timeout=60.0,
)
resp.raise_for_status()
return resp.json()
except Exception as e:
last_exc = e
status = getattr(getattr(e, "response", None), "status_code", None)
if status == 429:
delay = min(2 ** attempt, 16) + random.uniform(0, 1)
logger.warning("bzzoiro 429, retry %d in %.1fs", attempt + 1, delay)
await asyncio.sleep(delay)
continue
if 500 <= (status or 0) < 600:
delay = min(2 ** attempt, 16) + random.uniform(0, 1)
logger.warning("bzzoiro %d, retry %d in %.1fs", status, attempt + 1, delay)
await asyncio.sleep(delay)
continue
# 网络错误(连接失败/超时)也退避重试
if isinstance(e, (TimeoutError, ConnectionError, OSError)):
delay = min(2 ** attempt, 16) + random.uniform(0, 1)
logger.warning("bzzoiro network error, retry %d in %.1fs: %s", attempt + 1, delay, e)
await asyncio.sleep(delay)
continue
raise
raise RuntimeError(f"bzzoiro request failed after {max_retries} attempts: {last_exc}")
async def fetch_bzzoiro_events(
league_code: str,
*,
status: str = "finished",
date_from: str | None = None,
date_to: str | None = None,
limit: int = 200,
) -> list[dict]:
"""抓取 bzzoiro 原始事件(纯异步,无需 run_in_executor)。"""
league_id = BZZOIRO_LEAGUE_IDS.get(league_code)
if league_id is None:
raise ValueError(f"未知联赛代码: {league_code}")
rows: list[dict] = []
offset = 0
while True:
params: dict = {
"league_id": league_id,
"status": status,
"limit": limit,
"offset": offset,
}
if date_from:
params["date_from"] = str(date_from)[:10]
if date_to:
params["date_to"] = str(date_to)[:10]
payload = await _fetch_json_async("/events/", params)
batch = payload.get("results") or []
if not batch:
break
rows.extend(batch)
total = payload.get("total")
offset += limit
if total is not None and offset >= total:
break
if len(batch) < limit:
break
await asyncio.sleep(REQUEST_INTERVAL)
return rows
@register
class BzzoiroSource:
"""bzzoiro 数据源(实现 DataSource 协议)。"""
name = "bzzoiro"
async def ingest(
self,
db,
*,
leagues: Iterable[str],
date_from: str | None = None,
date_to: str | None = None,
status: str = "finished",
) -> dict:
"""采集 bzzoiro → 入库。返回统计。
注意: 本方法不控制事务(commit/rollback),由调用方通过 UnitOfWork 控制
"""
result: dict = {"leagues": {}, "total_inserted": 0, "total_updated": 0, "errors": []}
for code in leagues:
league_r: dict = {"inserted": 0, "updated": 0, "errors": []}
try:
raw_events = await fetch_bzzoiro_events(code, status=status, date_from=date_from, date_to=date_to)
except Exception as e:
logger.exception("bzzoiro fetch failed for %s", code)
league_r["errors"].append(f"fetch failed: {e}")
result["leagues"][code] = league_r
continue
# 获取或创建联赛
stmt = select(League).where(League.code == code)
league = (await db.execute(stmt)).scalar_one_or_none()
if league is None:
league = League(code=code, name=LEAGUE_NAMES.get(code, code), country=LEAGUE_COUNTRIES.get(code))
db.add(league)
await db.flush()
# === 批量优化: 预加载球队和已有比赛到内存 ===
team_name_to_id: dict[str, int] = {}
existing_matches: dict[tuple[int, int, str], Match] = {} # 完整对象,避免重复查询
# (NormalizedMatch, 原始 event) 成对保存:后续写 source_record_id 时
# 必须用配对的那条 event,不能依赖外层循环变量残留值。
normalized_matches: list[tuple] = []
if raw_events:
# 一次遍历: 收集球队名 + 规范化
all_team_names = set()
for raw in raw_events:
nm = normalize_bzzoiro(raw, code)
if nm is not None:
try:
nm.validate()
except Exception as e:
# P1-3: 统一使用 warning,不追加到 errors(仅运行时错误入 errors)
logger.warning("normalize skip: %s", e)
continue
normalized_matches.append((nm, raw))
all_team_names.add(nm.home_team)
all_team_names.add(nm.away_team)
if all_team_names:
stmt = select(Team).where(Team.name.in_(all_team_names))
teams = (await db.execute(stmt)).scalars().all()
team_name_to_id = {t.name: t.id for t in teams}
# P1-2: 按需加载,只加载 raw_events 涉及日期范围的比赛(加 30 天缓冲)
# 避免加载联赛全部历史比赛到内存(多赛季采集时内存溢出)
if normalized_matches:
from datetime import timedelta
# normalized_matches 存的是 (nm, raw) 元组,遍历需解包
dates = [nm.date for nm, _raw in normalized_matches if nm.date is not None]
if dates:
min_dt = min(dates) - timedelta(days=30)
max_dt = max(dates) + timedelta(days=30)
stmt = (
select(Match)
.where(Match.league_id == league.id)
.where(Match.match_date >= min_dt)
.where(Match.match_date <= max_dt)
)
existing_matches = {
_match_key(m.home_team_id, m.away_team_id, m.match_date_date): m
for m in (await db.execute(stmt)).scalars()
}
# else: existing_matches 保持空 dict(全量新比赛)
for nm, raw in normalized_matches:
# 球队: 内存查找 + 按需创建
home_team_id = team_name_to_id.get(nm.home_team)
if home_team_id is None:
home = Team(name=nm.home_team, name_zh=zh_name(nm.home_team))
db.add(home)
await db.flush()
home_team_id = home.id
team_name_to_id[nm.home_team] = home_team_id
away_team_id = team_name_to_id.get(nm.away_team)
if away_team_id is None:
away = Team(name=nm.away_team, name_zh=zh_name(nm.away_team))
db.add(away)
await db.flush()
away_team_id = away.id
team_name_to_id[nm.away_team] = away_team_id
# 查找已有比赛: 内存查找
match_key = _match_key(home_team_id, away_team_id, nm.date)
existing_match = existing_matches.get(match_key)
if existing_match is None:
m = Match(
league_id=league.id,
season=nm.season_label or None,
home_team_id=home_team_id,
away_team_id=away_team_id,
match_date=nm.date,
match_date_date=_to_date(nm.date),
match_status=nm.match_status,
home_goals=nm.home_goals,
away_goals=nm.away_goals,
home_ht_goals=nm.home_ht_goals,
away_ht_goals=nm.away_ht_goals,
match_stage=nm.match_stage,
)
db.add(m)
await db.flush()
existing_matches[match_key] = m # 防止同批重复
# 存在任一统计字段即可创建 MatchStats(不再强制要求 xG)
if any(getattr(nm, f) is not None for f in ['home_xg', 'away_xg', 'home_shots', 'away_shots', 'home_shots_on_target', 'away_shots_on_target', 'home_corners', 'away_corners', 'home_possession', 'home_yellow_cards', 'away_yellow_cards', 'home_red_cards', 'away_red_cards']):
now = datetime.now(timezone.utc)
# available_at 语义:统计「可被使用」的最早时间,至少不早于比赛结束
# 近似:开球 + 2 小时(实际完赛时间约为 +2 小时,非官方公布时间)
# 回测 cutoff 若贴着开球,不会把「完赛后才有的统计」误标为赛前可用
available_at = nm.date + timedelta(hours=2) if nm.date else now
stats = MatchStats(
match_id=m.id,
home_xg=nm.home_xg,
away_xg=nm.away_xg,
home_shots=nm.home_shots,
away_shots=nm.away_shots,
home_shots_on_target=nm.home_shots_on_target,
away_shots_on_target=nm.away_shots_on_target,
home_corners=nm.home_corners,
away_corners=nm.away_corners,
home_possession=nm.home_possession,
home_yellow_cards=nm.home_yellow_cards,
away_yellow_cards=nm.away_yellow_cards,
home_red_cards=nm.home_red_cards,
away_red_cards=nm.away_red_cards,
source="bzzoiro",
source_record_id=str(raw.get("id", "")),
retrieved_at=now,
available_at=available_at,
)
db.add(stats)
league_r["inserted"] += 1
else:
# 已有比赛: 直接从内存获取对象更新(无需再查询)
changed = False
if existing_match.match_status != nm.match_status and nm.match_status == "finished":
existing_match.match_status = nm.match_status
changed = True
if existing_match.home_goals is None and nm.home_goals is not None:
existing_match.home_goals = nm.home_goals
existing_match.away_goals = nm.away_goals
existing_match.home_ht_goals = nm.home_ht_goals
existing_match.away_ht_goals = nm.away_ht_goals
changed = True
if existing_match.match_stage is None and nm.match_stage:
existing_match.match_stage = nm.match_stage
changed = True
# 存在任一统计字段即可创建 MatchStats(不再强制要求 xG)
if existing_match.stats is None and (
nm.home_xg is not None or nm.away_xg is not None
or nm.home_shots is not None or nm.away_shots is not None
or nm.home_corners is not None or nm.away_corners is not None
or nm.home_possession is not None
):
now = datetime.now(timezone.utc)
# available_at 语义:统计「可被使用」的最早时间,至少不早于比赛结束
# 近似:开球 + 2 小时(实际完赛时间约为 +2 小时,非官方公布时间)
available_at = nm.date + timedelta(hours=2) if nm.date else now
existing_match.stats = MatchStats(
match_id=existing_match.id,
source="bzzoiro",
source_record_id=str(raw.get("id", "")),
retrieved_at=now,
available_at=available_at,
)
db.add(existing_match.stats)
await db.flush()
if existing_match.stats is not None:
for fld in ("home_xg", "away_xg", "home_shots", "away_shots",
"home_shots_on_target", "away_shots_on_target",
"home_corners", "away_corners", "home_possession",
"home_yellow_cards", "away_yellow_cards",
"home_red_cards", "away_red_cards"):
if getattr(existing_match.stats, fld, None) is None:
v = getattr(nm, fld, None)
if v is not None:
setattr(existing_match.stats, fld, v)
changed = True
if changed:
league_r["updated"] += 1
# 注意: 不在此处 commit,由调用方 UnitOfWork 控制事务
result["leagues"][code] = league_r
result["total_inserted"] += league_r["inserted"]
result["total_updated"] += league_r["updated"]
return result
# ── stats 回填管线 ──
from src.data.bzzoiro_stats import ( # noqa: F401
_pick,
_stats_from_payload,
ingest_bzzoiro_event_stats,
)
+123
View File
@@ -0,0 +1,123 @@
"""bzzoiro 管线共享原语:HTTP 抓取(多 key 轮换)与宽松字段转换。
bzzoiro.py 拆出(单文件 多模块):仅放无业务语义的共享基础,
三条管线(events/standings/stats)与聚合门面见 bzzoiro.py
"""
from __future__ import annotations
import asyncio
import logging
import random
import httpx
from src.core.http_client import get_client
from src.core.runtime_config import get_runtime_value
from src.data.key_ring import _mask, get_key_ring
logger = logging.getLogger(__name__)
def _to_date(value):
"""把 datetime / date / str 统一成 `date`。"""
if value is None:
return None
if hasattr(value, "date") and callable(value.date):
return value.date()
return value
def _to_int_or_none(value) -> int | None:
"""宽松转 int(用于上游 ID 解析,失败返回 None 不抛错)。"""
if value is None:
return None
try:
return int(str(value).strip())
except (TypeError, ValueError):
return None
def _to_float_or_none(value) -> float | None:
try:
return float(str(value).strip())
except (TypeError, ValueError):
return None
def _match_key(home_team_id: int, away_team_id: int, match_date) -> tuple[int, int, str]:
"""比赛去重键:(主队, 客队, 天级日期 ISO 字符串)。
统一在这里构造,避免"预加载时用 str(date)、写入时用 isoformat()"这类
隐式格式依赖 两者当前恰好相等,但一旦有人改动其一就会静默失配,
导致所有比赛被判为不存在而重复插入
"""
d = _to_date(match_date)
return (home_team_id, away_team_id, d.isoformat() if d is not None else "")
async def _fetch_json_async(path: str, params: dict | None = None, max_retries: int = 3) -> dict | list:
"""异步 HTTP(bzzoiro 使用 httpx,不再阻塞事件循环线程池)。
key 轮换:遇到 429 自动切换到下一个 key;全部 key 冷却时等待最早恢复
"""
base = (await get_runtime_value("BZZOIRO_BASE")).rstrip("/")
raw_keys = await get_runtime_value("BZZOIRO_KEY")
ring = get_key_ring(base, raw_keys)
url = f"{base}/{path.lstrip('/')}"
key = ring.get()
if not key:
raise RuntimeError("BZZOIRO_KEY 未设置")
last_exc: Exception | None = None
for attempt in range(max_retries):
headers = {
"Authorization": f"Token {key}",
"Accept": "application/json",
}
try:
client = get_client()
# 整请求兜底: httpx 无 total 超时,用 wait_for 防「滴水式」限速挂死
resp = await asyncio.wait_for(
client.get(
url, headers=headers, params=params,
timeout=httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0),
),
timeout=60.0,
)
resp.raise_for_status()
return resp.json()
except Exception as e:
last_exc = e
status = getattr(getattr(e, "response", None), "status_code", None)
if status == 429:
# 限流:标记当前 key 冷却,切换到下一个
new_key = ring.report_rate_limited(key)
if new_key and new_key != key:
logger.info("bzzoiro 429 → 切换 key: %s%s,立即重试", _mask(key), _mask(new_key))
key = new_key
continue # 立即重试,不等待
# 单 key 或全部冷却:等待最早恢复的 key
wait = ring.wait_if_all_blocked()
if wait > 0:
logger.warning("bzzoiro 全部 key 冷却,等待 %.1fs 后重试", wait)
await asyncio.sleep(min(wait, 30.0))
else:
delay = min(2 ** attempt, 16) + random.uniform(0, 1)
logger.warning("bzzoiro 429, retry %d in %.1fs", attempt + 1, delay)
await asyncio.sleep(delay)
key = ring.get() or key
continue
if 500 <= (status or 0) < 600:
delay = min(2 ** attempt, 16) + random.uniform(0, 1)
logger.warning("bzzoiro %d, retry %d in %.1fs", status, attempt + 1, delay)
await asyncio.sleep(delay)
continue
# 网络错误(连接失败/超时)也退避重试
if isinstance(e, (TimeoutError, ConnectionError, OSError)):
delay = min(2 ** attempt, 16) + random.uniform(0, 1)
logger.warning("bzzoiro network error, retry %d in %.1fs: %s", attempt + 1, delay, e)
await asyncio.sleep(delay)
continue
raise
raise RuntimeError(f"bzzoiro request failed after {max_retries} attempts: {last_exc}")
+327
View File
@@ -0,0 +1,327 @@
"""bzzoiro events 管线:比赛日程/比分抓取(/events/)与入库(matches 表)。
bzzoiro.py 拆出比赛主数据唯一入口;Team/League/Match 查找/创建经
Repository ,事务由调用方 UnitOfWork 控制(分批事务约定不变)
可替换协作者(抓取函数 / Bronze 写入助手 / REQUEST_INTERVAL)在运行期
经聚合门面 src.data.bzzoiro 解析 与拆分前的单文件 monkeypatch 语义一致
"""
from __future__ import annotations
import asyncio
import logging
from collections.abc import Iterable
from datetime import datetime, timedelta, timezone
from src.data.bzzoiro_common import _match_key, _to_date, _to_int_or_none
from src.data.config import BZZOIRO_LEAGUE_IDS, LEAGUE_COUNTRIES, LEAGUE_NAMES
from src.data.normalize import normalize_bzzoiro
from src.data.pipeline_write import _safe_write_ingest_failure, _write_lineage, _write_raw_event
from src.data.sources import register
from src.data.team_names_zh import zh_name
from src.db.models import Match
from src.db.repositories import LeagueRepository, MatchRepository, TeamRepository
logger = logging.getLogger(__name__)
async def fetch_bzzoiro_events(
league_code: str,
*,
status: str = "finished",
date_from: str | None = None,
date_to: str | None = None,
limit: int = 200,
) -> list[dict]:
"""抓取 bzzoiro 原始事件(纯异步,无需 run_in_executor)。"""
from src.data import bzzoiro as bz
league_id = BZZOIRO_LEAGUE_IDS.get(league_code)
if league_id is None:
raise ValueError(f"未知联赛代码: {league_code}")
rows: list[dict] = []
offset = 0
while True:
params: dict = {
"league_id": league_id,
"status": status,
"limit": limit,
"offset": offset,
}
if date_from:
params["date_from"] = str(date_from)[:10]
if date_to:
params["date_to"] = str(date_to)[:10]
payload = await bz._fetch_json_async("/events/", params)
batch = payload.get("results") or []
if not batch:
break
rows.extend(batch)
total = payload.get("total")
offset += limit
if total is not None and offset >= total:
break
if len(batch) < limit:
break
await asyncio.sleep(bz.REQUEST_INTERVAL)
return rows
@register
class BzzoiroSource:
"""bzzoiro 数据源(实现 DataSource 协议)。"""
name = "bzzoiro"
async def ingest(
self,
db,
*,
leagues: Iterable[str],
date_from: str | None = None,
date_to: str | None = None,
status: str = "finished",
) -> dict:
"""采集 bzzoiro → 入库。返回统计。
注意: 本方法不控制事务(commit/rollback),由调用方通过 UnitOfWork 控制
"""
from src.data import bzzoiro as bz
result: dict = {"leagues": {}, "total_inserted": 0, "total_updated": 0, "errors": []}
for code in leagues:
league_r: dict = {"inserted": 0, "updated": 0, "errors": []}
try:
raw_events = await bz.fetch_bzzoiro_events(code, status=status, date_from=date_from, date_to=date_to)
except Exception as e:
# 单联赛抓取失败隔离:记录错误后继续其余联赛,不拖垮整批
logger.exception("bzzoiro fetch failed for %s", code)
league_r["errors"].append(f"fetch failed: {e}")
await _safe_write_ingest_failure(
db,
entity_type="events",
source_record_id=None,
error=e,
raw_payload={"league": code, "status": status, "date_from": date_from, "date_to": date_to},
)
result["leagues"][code] = league_r
continue
# D4: 联赛查找/创建经 LeagueRepository(事务仍由调用方 UoW 提交)
league = await LeagueRepository(db).get_or_create(
code, LEAGUE_NAMES.get(code, code), LEAGUE_COUNTRIES.get(code)
)
team_r = TeamRepository(db)
match_r = MatchRepository(db)
# === 批量优化: 预加载球队和已有比赛到内存 ===
team_name_to_id: dict[str, int] = {}
existing_matches: dict[tuple[int, int, str], Match] = {} # 完整对象,避免重复查询
# (NormalizedMatch, 原始 event) 成对保存:后续写 source_event_id 时
# 必须用配对的那条 event,不能依赖外层循环变量残留值。
normalized_matches: list[tuple] = []
if raw_events:
# 一次遍历: 收集球队名 + 规范化
all_team_names = set()
for raw in raw_events:
nm = normalize_bzzoiro(raw, code)
if nm is not None:
try:
nm.validate()
except Exception:
continue
normalized_matches.append((nm, raw))
all_team_names.add(nm.home_team)
all_team_names.add(nm.away_team)
if all_team_names:
team_name_to_id = {
name: t.id
for name, t in (await team_r.get_all_by_names(list(all_team_names))).items()
}
# P1-2: 按需加载,只加载 raw_events 涉及日期范围的比赛(加 30 天缓冲)
# 避免加载联赛全部历史比赛到内存(多赛季采集时内存溢出)
if normalized_matches:
# normalized_matches 存的是 (nm, raw) 元组,遍历需解包
dates = [nm.date for nm, _raw in normalized_matches if nm.date is not None]
if dates:
min_dt = min(dates) - timedelta(days=30)
max_dt = max(dates) + timedelta(days=30)
matches_in_range = await match_r.find_by_league_and_date_range(
league.id, min_dt, max_dt
)
existing_matches = {
_match_key(m.home_team_id, m.away_team_id, m.match_date_date): m
for m in matches_in_range
}
# else: existing_matches 保持空 dict(全量新比赛)
# D1: Bronze 层批次信息(每联赛每批次一个 batch_id;seen 防同批重复写入)
now = datetime.now(timezone.utc)
bronze_batch_id = f"bzzoiro-events-{code}-{now:%Y%m%d%H%M%S}"
bronze_written: set[str] = set()
for nm, raw in normalized_matches:
# D1: RawEvent 幂等键(上游 id 或合成键),插入/变更更新共用
record_id = _events_record_id(code, nm, raw)
# 球队: 内存查找 + 按需创建(D4: 经 TeamRepository)
home_team_id = team_name_to_id.get(nm.home_team)
if home_team_id is None:
home = await team_r.get_or_create(nm.home_team, name_zh=zh_name(nm.home_team))
home_team_id = home.id
team_name_to_id[nm.home_team] = home_team_id
away_team_id = team_name_to_id.get(nm.away_team)
if away_team_id is None:
away = await team_r.get_or_create(nm.away_team, name_zh=zh_name(nm.away_team))
away_team_id = away.id
team_name_to_id[nm.away_team] = away_team_id
# 查找已有比赛:优先按 upstream event_id 定位(命中即唯一),
# 否则回退自然键(联赛+主客+天级日期)内存查找。
# source_event_id 上有 partial unique 索引保障 upstream 唯一。
eid = _to_int_or_none(raw.get("id"))
existing_match = None
if eid is not None:
existing_match = await match_r.find_by_source_event_id(eid)
if existing_match is None:
match_key = _match_key(home_team_id, away_team_id, nm.date)
existing_match = existing_matches.get(match_key)
if existing_match is None:
m = Match(
league_id=league.id,
season=nm.season_label or None,
home_team_id=home_team_id,
away_team_id=away_team_id,
match_date=nm.date,
match_date_date=_to_date(nm.date),
match_status=nm.match_status,
score_status=nm.score_status,
home_goals=nm.home_goals,
away_goals=nm.away_goals,
home_ht_goals=nm.home_ht_goals,
away_ht_goals=nm.away_ht_goals,
match_stage=nm.match_stage,
source_event_id=_to_int_or_none(raw.get("id")),
)
db.add(m)
await db.flush()
existing_matches[match_key] = m # 防止同批重复
# 统计字段不在 /events/ 载荷中(单独由 stats 管线回填),
# 此处不再创建 MatchStats。
league_r["inserted"] += 1
# D1: 成功插入 → 补写 Bronze 层(原始载荷 + 血缘)
if record_id not in bronze_written:
bronze_written.add(record_id)
await _write_events_bronze(
db,
source_record_id=record_id,
raw_payload=raw,
target_match_id=m.id,
league_code=code,
match_status=nm.match_status,
batch_id=bronze_batch_id,
)
else:
# 已有比赛: 直接从内存获取对象更新(无需再查询)
changed = False
if existing_match.match_status != nm.match_status and nm.match_status == "finished":
existing_match.match_status = nm.match_status
changed = True
if existing_match.home_goals is None and nm.home_goals is not None:
existing_match.home_goals = nm.home_goals
existing_match.away_goals = nm.away_goals
existing_match.home_ht_goals = nm.home_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
if existing_match.match_stage is None and nm.match_stage:
existing_match.match_stage = nm.match_stage
changed = True
if existing_match.source_event_id is None:
eid = _to_int_or_none(raw.get("id"))
if eid is not None:
existing_match.source_event_id = eid
changed = True
if changed:
league_r["updated"] += 1
# D1: 变更更新 → 补写血缘(RawEvent 幂等键不变,重复采集自动跳过)
if record_id not in bronze_written:
bronze_written.add(record_id)
await _write_events_bronze(
db,
source_record_id=record_id,
raw_payload=raw,
target_match_id=existing_match.id,
league_code=code,
match_status=nm.match_status,
batch_id=bronze_batch_id,
)
# 注意: 不在此处 commit,由调用方 UnitOfWork 控制事务
result["leagues"][code] = league_r
result["total_inserted"] += league_r["inserted"]
result["total_updated"] += league_r["updated"]
return result
def _events_record_id(league_code: str, nm, raw: dict) -> str:
"""events 载荷的 RawEvent 幂等键。
优先用上游 event id;缺失时用 (league:home:away:date) 合成稳定键
normalize 后的队名与天级日期( _match_key 同口径),不依赖 DB 自增 id,
保证同一来源比赛重复采集时命中同一条 RawEvent,不产生重复原始载荷
"""
eid = _to_int_or_none(raw.get("id"))
if eid is not None:
return str(eid)
d = _to_date(nm.date)
date_part = d.isoformat() if d is not None else "na"
return f"{league_code}:{nm.home_team}:{nm.away_team}:{date_part}"
async def _write_events_bronze(
db,
*,
source_record_id: str,
raw_payload: dict,
target_match_id: int | None,
league_code: str,
match_status: str | None,
batch_id: str,
) -> None:
"""events 成功插入/更新单场比赛后的 Bronze 层补写:RawEvent(幂等) + DataLineage。
D1(工程债):此前只有 stats 回填写 RawEvent/Lineage,events 管线作为比赛
主数据的唯一入口反而不留溯源记录幂等性由 _write_raw_event
source_record_id 查重保证;best-effort:基础设施写入失败只记 warning,
绝不拖垮采集主流程( _safe_write_ingest_failure 同级约束)
"""
try:
await _write_raw_event(db, "bzzoiro", source_record_id, raw_payload, batch_id)
await _write_lineage(
db, "bzzoiro", source_record_id,
"matches", target_match_id, "events_ingest",
{"league": league_code, "match_status": match_status},
batch_id,
)
except Exception:
logger.warning(
"events Bronze 写入失败(record=%s, match=%s),不影响采集主流程",
source_record_id, target_match_id, exc_info=True,
)
+207
View File
@@ -0,0 +1,207 @@
"""bzzoiro standings 管线:联赛积分榜快照(/leagues/{id}/standings/)→ standings 表。
bzzoiro.py 拆出同一联赛同一赛季只保留最新快照( (league, season, team)
upsert);球队名与 events 管线使用同一 normalize 规则,保证 Team 匹配
可替换协作者(抓取函数 / Bronze 写入助手)在运行期经聚合门面
src.data.bzzoiro 解析 与拆分前的单文件 monkeypatch 语义一致
"""
from __future__ import annotations
import logging
from collections.abc import Iterable
from datetime import datetime, timezone
from sqlalchemy import select
from src.data.bzzoiro_common import _to_float_or_none, _to_int_or_none
from src.data.config import BZZOIRO_LEAGUE_IDS, LEAGUE_COUNTRIES, LEAGUE_NAMES
from src.data.pipeline_write import _safe_write_ingest_failure, _write_lineage, _write_raw_event
from src.data.team_names_zh import zh_name
from src.db.models import Standing, Team
from src.db.repositories import LeagueRepository, TeamRepository
logger = logging.getLogger(__name__)
async def fetch_bzzoiro_standings(league_code: str, season: str | None = None) -> dict:
"""抓取联赛积分榜(纯抓取,不入库)。season 为 None 时取当前赛季。"""
from src.data import bzzoiro as bz
league_id = BZZOIRO_LEAGUE_IDS.get(league_code)
if league_id is None:
raise ValueError(f"未知联赛代码: {league_code}")
params: dict = {}
if season:
params["season"] = season
return await bz._fetch_json_async(f"/leagues/{league_id}/standings/", params)
def _season_label_from_dates(start_date, end_date) -> str:
"""从赛季起止日期推导赛季标签(与 derive_season_label 语义一致)。"""
try:
if isinstance(start_date, str):
start = datetime.fromisoformat(start_date[:10])
else:
start = start_date
if start is None:
return "?"
y = start.year
return f"{y}-{y + 1}" if start.month >= 8 else f"{y - 1}-{y}"
except (TypeError, ValueError):
return "?"
async def ingest_bzzoiro_standings(db, *, leagues: Iterable[str], season: str | None = None) -> dict:
"""采集积分榜 → upsert standings 表。
season None 时采集当前赛季(bzzoiro 默认返回 is_current 赛季)
球队名与 events 管线使用同一 normalize 规则,保证 Team 匹配
"""
from src.data.team_names import normalize as normalize_name
from src.data import bzzoiro as bz
result: dict = {"leagues": {}, "total_upserted": 0, "errors": []}
for code in leagues:
league_r: dict = {"upserted": 0, "teams_created": 0, "rows": 0, "errors": []}
try:
payload = await bz.fetch_bzzoiro_standings(code, season=season)
except Exception as e:
logger.exception("bzzoiro standings fetch failed for %s", code)
league_r["errors"].append(str(e))
await _safe_write_ingest_failure(
db,
entity_type="standings",
source_record_id=None,
error=e,
raw_payload={"league": code, "season": season},
)
result["leagues"][code] = league_r
result["errors"].append(f"{code}: {e}")
continue
rows = payload.get("standings") or []
if not rows:
result["leagues"][code] = {"error": "无积分榜数据(赛季未开始或未提供)"}
result["errors"].append(f"{code}: 无积分榜数据")
continue
# 联赛(get-or-create,D4: 经 LeagueRepository)
league = await LeagueRepository(db).get_or_create(
code, LEAGUE_NAMES.get(code, code), LEAGUE_COUNTRIES.get(code)
)
team_r = TeamRepository(db)
# 赛季标签:优先用返回的 season 对象推导
season_obj = payload.get("season") or {}
season_label = _season_label_from_dates(
season_obj.get("start_date"), season_obj.get("end_date")
)
if season_label == "?":
season_label = season or ""
# 批量预载球队(与 events 管线使用同一 normalize 规则,保证 Team 匹配)
names = {normalize_name(str(r.get("team_name", ""))) for r in rows}
names.discard("")
team_map: dict[str, Team] = await team_r.get_all_by_names(list(names))
now = datetime.now(timezone.utc)
for r in rows:
team_name = normalize_name(str(r.get("team_name", "")))
if not team_name:
continue
team = team_map.get(team_name)
if team is None:
team = await team_r.get_or_create(team_name, name_zh=zh_name(team_name))
team_map[team_name] = team
league_r["teams_created"] += 1
zone = r.get("zone") or {}
values = dict(
position=_to_int_or_none(r.get("position")) or 0,
played=_to_int_or_none(r.get("played")) or 0,
won=_to_int_or_none(r.get("won")) or 0,
drawn=_to_int_or_none(r.get("drawn")) or 0,
lost=_to_int_or_none(r.get("lost")) or 0,
goals_for=_to_int_or_none(r.get("gf")) or 0,
goals_against=_to_int_or_none(r.get("ga")) or 0,
goal_diff=_to_int_or_none(r.get("gd")) or 0,
points=_to_int_or_none(r.get("pts")) or 0,
xg_for=_to_float_or_none(r.get("xgf")),
xg_against=_to_float_or_none(r.get("xga")),
form=r.get("form") or None,
zone=zone.get("label") or zone.get("key") or None,
updated_at=now,
retrieved_at=now,
)
# P0-02: 追加快照——每次采集 INSERT 新行(available_at=now),
# ON CONFLICT (league, season, team, available_at) DO NOTHING。
standing = Standing(
league_id=league.id, season=season_label, team_id=team.id, available_at=now, **values
)
db.add(standing)
league_r["upserted"] += 1
league_r["rows"] = len(rows)
# D1(对称 events/stats 管线): 联赛成功 upsert → 补写 Bronze 层。
# 幂等键 standings:{league}:{season}:积分榜是联赛级快照,一次成功
# 采集写一条 RawEvent(整份原始载荷)+ 一条血缘。season 用实际入库的
# 标签(由载荷推导,与 Standing.season 同口径),不依赖调用方传参,
# 保证不同调用方(season=None 或显式传参)对同一赛季命中同一条 RawEvent。
if league_r["upserted"] > 0:
bronze_batch_id = f"bzzoiro-standings-{code}-{now:%Y%m%d%H%M%S}"
await _write_standings_bronze(
db,
source_record_id=f"standings:{code}:{season_label}",
raw_payload=payload,
league_id=league.id,
league_code=code,
season_label=season_label,
rows_upserted=league_r["upserted"],
batch_id=bronze_batch_id,
)
result["leagues"][code] = league_r
result["total_upserted"] += league_r["upserted"]
logger.info(
"bzzoiro standings 采集完成: %s 赛季 %s, upsert %d/%d",
code, season_label, league_r["upserted"], league_r["rows"],
)
return result
async def _write_standings_bronze(
db,
*,
source_record_id: str,
raw_payload: dict,
league_id: int | None,
league_code: str,
season_label: str,
rows_upserted: int,
batch_id: str,
) -> None:
"""standings 成功 upsert 一个联赛后的 Bronze 层补写:RawEvent(幂等) + DataLineage。
_write_events_bronze 同级约束:幂等性由 _write_raw_event
source_record_id 查重保证(积分榜是联赛级快照,同联赛同赛季重复采集
命中同一条 RawEvent);best-effort:基础设施写入失败只记 warning,
绝不拖垮采集主流程
"""
try:
await _write_raw_event(db, "bzzoiro", source_record_id, raw_payload, batch_id)
await _write_lineage(
db, "bzzoiro", source_record_id,
"standings", league_id, "standings_ingest",
{"league": league_code, "season": season_label, "rows_upserted": rows_upserted},
batch_id,
)
except Exception:
logger.warning(
"standings Bronze 写入失败(record=%s, league=%s),不影响采集主流程",
source_record_id, league_code, exc_info=True,
)
+181
View File
@@ -0,0 +1,181 @@
"""bzzoiro stats 回填管线:已完赛比赛详细统计(/events/{id}/stats/)→ match_stats 表。
bzzoiro.py 拆出上游限速(REQUEST_INTERVAL /请求),大批量回填需分次触发;
add/flush commit,事务由调用方 UnitOfWork 控制
可替换协作者(_fetch_json_async / Bronze 写入助手 / REQUEST_INTERVAL)在运行期
经聚合门面 src.data.bzzoiro 解析 与拆分前的单文件 monkeypatch 语义一致
"""
from __future__ import annotations
import asyncio
import logging
from collections.abc import Iterable
from datetime import datetime, timedelta, timezone
from src.data.bzzoiro_common import _to_float_or_none, _to_int_or_none
from src.data.config import BZZOIRO_LEAGUE_IDS
from src.data.pipeline_write import _safe_write_ingest_failure, _write_lineage, _write_raw_event
from src.db.models import MatchStats
from src.db.repositories import MatchRepository
logger = logging.getLogger(__name__)
# bzzoiro stats 字段 → MatchStats 字段映射(stats.home / stats.away 下)
_STATS_FIELD_MAP = {
"xg": ("home_xg", "away_xg"), # 回退 expected_goals
"ball_possession": ("home_possession", None), # 只取主队值,客队=100-home
"total_shots": ("home_shots", "away_shots"),
"shots_on_target": ("home_shots_on_target", "away_shots_on_target"),
"corner_kicks": ("home_corners", "away_corners"),
"yellow_cards": ("home_yellow_cards", "away_yellow_cards"),
"red_cards": ("home_red_cards", "away_red_cards"),
"big_chances": ("home_big_chances", "away_big_chances"),
"fouls": ("home_fouls", "away_fouls"),
}
def _pick(d: dict, *keys):
"""按优先级取第一个非空字段值。"""
for k in keys:
v = d.get(k)
if v is not None:
return v
return None
def _stats_from_payload(payload: dict) -> dict:
"""把 /events/{id}/stats/ 响应映射成 MatchStats 字段 dict。
响应结构: {"event_id": ..., "stats": {"home": {...}, "away": {...}}}
"""
stats = (payload or {}).get("stats") or {}
home = stats.get("home") or {}
away = stats.get("away") or {}
out: dict = {}
xg_h = _pick(home, "xg", "expected_goals")
xg_a = _pick(away, "xg", "expected_goals")
if xg_h is not None:
out["home_xg"] = _to_float_or_none(xg_h)
if xg_a is not None:
out["away_xg"] = _to_float_or_none(xg_a)
poss = home.get("ball_possession")
if poss is not None:
p = _to_float_or_none(poss)
if p is not None:
out["home_possession"] = p
for src, (h_fld, a_fld) in _STATS_FIELD_MAP.items():
if src in ("xg", "ball_possession"):
continue # 已处理
hv = home.get(src)
av = away.get(src)
if hv is not None and h_fld:
out[h_fld] = _to_int_or_none(hv)
if av is not None and a_fld:
out[a_fld] = _to_int_or_none(av)
return out
async def ingest_bzzoiro_event_stats(
db,
*,
leagues: Iterable[str],
limit: int = 100,
only_missing: bool = True,
) -> dict:
"""回填已完赛比赛的详细统计(逐场调 /events/{id}/stats/)。
筛选条件: match_status=finished source_event_id 非空
only_missing=True 时跳过已有统计的比赛(增量);False 则全量刷新
limit 控制单次最多处理的比赛数(上游限速约 1.2s/请求,大批量需分次触发)
"""
from src.data import bzzoiro as bz
result: dict = {"fetched": 0, "created": 0, "updated": 0, "skipped": 0, "errors": []}
league_ids = [BZZOIRO_LEAGUE_IDS[c] for c in leagues if c in BZZOIRO_LEAGUE_IDS]
if not league_ids:
result["errors"].append("无有效联赛代码")
return result
# D4: 候选比赛查询经 MatchRepository(含 stats 预加载,筛选/排序/limit 语义不变)
matches = await MatchRepository(db).find_finished_with_stats(
league_ids, limit=limit * 3 if only_missing else limit
)
now = datetime.now(timezone.utc)
processed = 0
for m in matches:
if processed >= limit:
break
if only_missing and m.stats is not None and m.stats.home_shots is not None:
result["skipped"] += 1
continue
processed += 1
try:
payload = await bz._fetch_json_async(f"/events/{m.source_event_id}/stats/")
except Exception as e:
logger.warning("stats fetch failed match=%s event=%s: %s", m.id, m.source_event_id, e)
result["errors"].append(f"match {m.id}: {e}")
await _safe_write_ingest_failure(
db,
entity_type="match_stats",
source_record_id=str(m.source_event_id),
error=e,
raw_payload={"match_id": m.id},
)
await asyncio.sleep(bz.REQUEST_INTERVAL)
continue
result["fetched"] += 1
fields = _stats_from_payload(payload)
if not fields:
result["skipped"] += 1
await asyncio.sleep(bz.REQUEST_INTERVAL)
continue
if m.stats is None:
available_at = m.match_date + timedelta(hours=2) if m.match_date else now
m.stats = MatchStats(
match_id=m.id,
source="bzzoiro",
source_record_id=str(m.source_event_id),
retrieved_at=now,
available_at=available_at,
)
db.add(m.stats)
result["created"] += 1
else:
result["updated"] += 1
if m.stats.source is None:
m.stats.source = "bzzoiro"
m.stats.source_record_id = str(m.source_event_id)
if m.stats.retrieved_at is None:
m.stats.retrieved_at = now
if m.stats.available_at is None and m.match_date:
m.stats.available_at = m.match_date + timedelta(hours=2)
for fld, v in fields.items():
if hasattr(m.stats, fld):
setattr(m.stats, fld, v)
# 管线基础设施:写入 RawEvent + DataLineage
batch_id = f"bzzoiro-stats-{m.source_event_id}-{now.strftime('%Y%m%d%H%M%S')}"
try:
await _write_raw_event(db, "bzzoiro", str(m.source_event_id), payload, batch_id)
await _write_lineage(db, "bzzoiro", str(m.source_event_id), "match_stats", m.stats.id if m.stats else None, "stats_backfill", {"match_id": m.id}, batch_id)
except Exception:
pass # 基础设施写入失败不影响主流程
await asyncio.sleep(bz.REQUEST_INTERVAL)
logger.info(
"bzzoiro stats 回填完成: 抓取 %d, 新建 %d, 更新 %d, 跳过 %d, 错误 %d",
result["fetched"], result["created"], result["updated"],
result["skipped"], len(result["errors"]),
)
return result
+1 -10
View File
@@ -1,4 +1,4 @@
"""数据源配置常量(联赛映射)。"""
"""数据源配置常量(联赛映射)。数据源统一为 bzzoiro(单一数据源)。"""
from __future__ import annotations
# fdco 风格代码 → bzzoiro league_id
@@ -12,15 +12,6 @@ BZZOIRO_LEAGUE_IDS: dict[str, int] = {
"EL": 8, # Europa League
}
# fdco 代码 → understat 联赛代码
FDCO_TO_UNDERSTAT: dict[str, str] = {
"E0": "EPL",
"SP1": "La_liga",
"D1": "Bundesliga",
"I1": "Serie_A",
"F1": "Ligue_1",
}
# fdco 代码 → 显示名
LEAGUE_NAMES: dict[str, str] = {
"E0": "Premier League",
-347
View File
@@ -1,347 +0,0 @@
"""伤停数据采集器(api-football / api-sports.io)。
采集伤停数据并入库(injuries ), injuries agent 使用
"""
from __future__ import annotations
import asyncio
import json
import logging
import random
import tempfile
import time
from datetime import datetime, timezone
from pathlib import Path
from dataclasses import dataclass
from typing import Any
import httpx
from src.core.runtime_config import get_runtime_value
@dataclass
class InjuryQueryResult:
"""伤停查询结果(区分「查询成功但为空」与「查询失败/源未配置」)。"""
records: list["Injury"]
query_status: str # "success" | "source_not_configured" | "query_error"
@property
def has_data(self) -> bool:
"""成功查询(即使结果为空)视为有明确名单,has_data=True。"""
return self.query_status == "success"
from src.core.http_client import get_client
logger = logging.getLogger(__name__)
API_BASE = "https://v3.football.api-sports.io"
DEFAULT_HOST = "v3.football.api-sports.io"
# 缓存目录:系统临时目录
_CACHE_DIR = Path(tempfile.gettempdir()) / "profeto_injuries"
# Fix 5: 缓存 TTL 从 7 天改为 6 小时,同日再采不会命中旧数据
_CACHE_TTL_HOURS = 6
async def fetch_injuries(*, date: str | None = None, fixture_id: int | None = None, league_id: int | None = None) -> list[dict]:
"""采集伤停数据。
Args:
date: 日期 (YYYY-MM-DD),返当天全部伤停
fixture_id: 指定比赛 ID
league_id: 指定联赛 ID
Returns:
伤停记录列表
"""
api_key = await get_runtime_value("API_FOOTBALL_KEY")
if not api_key:
raise RuntimeError("API_FOOTBALL_KEY 未设置")
cache_dir = _CACHE_DIR
cache_dir.mkdir(parents=True, exist_ok=True)
# Fix 5: 缓存命中 (6 小时内有效)
cache_key = f"injuries_{date}_{fixture_id}_{league_id}.json"
cache_file = cache_dir / cache_key
if cache_file.exists():
age_hours = (time.time() - cache_file.stat().st_mtime) / 3600
if age_hours < _CACHE_TTL_HOURS:
logger.debug("injuries cache hit: %s (%.1fh old)", cache_key, age_hours)
with open(cache_file, encoding="utf-8") as f:
return json.load(f)
else:
logger.debug("injuries cache expired: %s (%.1fh old)", cache_key, age_hours)
headers = {
"x-apisports-key": api_key,
"x-rapidapi-host": DEFAULT_HOST,
}
params: dict[str, Any] = {}
if date:
params["date"] = date
if fixture_id:
params["fixture"] = fixture_id
if league_id:
params["league"] = league_id
url = f"{API_BASE}/injuries"
# 重试
last_exc: Exception | None = None
for attempt in range(3):
try:
client = get_client()
resp = await asyncio.wait_for(
client.get(
url, headers=headers, params=params,
timeout=httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0),
),
timeout=60.0,
)
resp.raise_for_status()
break
except Exception as e:
last_exc = e
if attempt == 2:
raise
delay = min(2 ** attempt, 8) + random.uniform(0, 1)
logger.warning("injuries fetch failed, retry %d in %.1fs: %s", attempt + 1, delay, e)
await asyncio.sleep(delay)
else:
raise RuntimeError(f"injuries fetch failed: {last_exc}")
data = resp.json()
injuries = data.get("response", [])
# 写缓存
with open(cache_file, "w", encoding="utf-8") as f:
json.dump(injuries, ensure_ascii=False, default=str, fp=f)
return injuries
async def ingest_injuries(db, *, date: str | None = None) -> dict:
"""采集伤停数据并入库(injuries 表)。
注意: 本方法不控制事务(commit/rollback),由调用方通过 UnitOfWork 控制
Fix 1: 使用 SAVEPOINT(begin_nested)避免整批回滚丢数据
Fix 2: 正确解析并写入 return_date
Fix 3: retrieved_at 比较统一用 timezone-aware datetime
"""
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from src.data.team_names import normalize as normalize_name
from src.db.models import Injury, Team
result = {"count": 0, "inserted": 0, "errors": []}
try:
raw_injuries = await fetch_injuries(date=date)
except Exception as e:
logger.exception("injuries fetch failed")
result["errors"].append(f"fetch failed: {e}")
return result
result["count"] = len(raw_injuries)
# 预加载所有球队(用于按名匹配)
teams = (await db.execute(select(Team))).scalars().all()
team_by_name = {t.name: t.id for t in teams}
# 收集所有待插入记录(解析 + 校验)
pending_records: list[dict] = []
for raw in raw_injuries:
try:
player = raw.get("player", {}) or {}
team = raw.get("team", {}) or {}
fixture = raw.get("fixture", {}) or {}
player_name = player.get("name", "")
team_name = normalize_name(team.get("name", ""))
team_id = team_by_name.get(team_name)
# Fix 2: 解析日期(injury_date + return_date)
fixture_date = fixture.get("date")
injury_date = None
if fixture_date:
try:
dt = datetime.fromisoformat(fixture_date.replace("Z", "+00:00"))
injury_date = dt.date()
except (ValueError, AttributeError):
pass
# 解析 return_date(如果数据源提供)
return_date = None
return_date_raw = player.get("return_date") or player.get("returnDate")
if return_date_raw:
try:
dt = datetime.fromisoformat(str(return_date_raw).replace("Z", "+00:00"))
return_date = dt.date()
except (ValueError, AttributeError):
pass
# 强制 int 转换,API 可能返回字符串
player_id = player.get("id")
try:
player_id = int(player_id) if player_id is not None else None
except (ValueError, TypeError):
player_id = None
fixture_id = fixture.get("id")
try:
fixture_id = int(fixture_id) if fixture_id is not None else None
except (ValueError, TypeError):
fixture_id = None
pending_records.append({
"player_id": player_id,
"player_name": player_name,
"team_id": team_id,
"fixture_id": fixture_id,
"league_id": (raw.get("league") or {}).get("id"),
"injury_type": player.get("type"),
"reason": player.get("reason"),
"injury_date": injury_date,
"return_date": return_date,
})
except Exception as e:
result["errors"].append(f"parse error: {e}")
# 批量查询已存在的记录(1 次 DB 往返)
existing_keys: set[tuple] = set()
if pending_records:
conditions = []
for rec in pending_records:
conditions.append(
(Injury.player_id == rec["player_id"])
& (Injury.fixture_id == rec["fixture_id"])
& (Injury.injury_type == rec["injury_type"])
)
if conditions:
from sqlalchemy import or_
stmt = select(Injury.player_id, Injury.fixture_id, Injury.injury_type).where(or_(*conditions))
rows = (await db.execute(stmt)).all()
existing_keys = {(r[0], r[1], r[2]) for r in rows}
# Fix 1: 使用 begin_nested(SAVEPOINT)隔离每批 flush
# IntegrityError 时只回滚到 savepoint,不影响其它已成功批次
BATCH_SIZE = 50
batch: list[Injury] = []
async def _flush_batch():
"""使用 savepoint flush 一批记录;失败只回滚本批。返回实际写入条数。"""
if not batch:
return 0
count = len(batch)
async with db.begin_nested():
for obj in batch:
db.add(obj)
await db.flush()
batch.clear()
return count
for i, rec in enumerate(pending_records):
key = (rec["player_id"], rec["fixture_id"], rec["injury_type"])
if key in existing_keys:
continue
batch.append(Injury(**rec))
# 每 BATCH_SIZE 条 flush 一次
if len(batch) >= BATCH_SIZE:
try:
result["inserted"] += await _flush_batch()
except IntegrityError:
logger.warning(
"injuries batch IntegrityError at record %d, "
"rolled back to savepoint, continuing",
i + 1,
)
# begin_nested 已回滚到 savepoint,清空 batch 继续
batch.clear()
continue
# 最终 flush(剩余不足一批的记录)
try:
result["inserted"] += await _flush_batch()
except IntegrityError:
logger.warning(
"injuries final flush IntegrityError, "
"rolled back to savepoint, some records may be lost",
)
batch.clear()
# 注意: 不在此处 commit,由调用方 UnitOfWork 控制事务
logger.info("injuries: fetched %d, inserted %d for %s", result["count"], result["inserted"], date)
return result
async def get_injuries_for_match(db, team_id: int, match_date, as_of=None) -> InjuryQueryResult:
"""查询某场比赛前某队的伤停名单(比赛日仍缺阵的)。
Args:
db: 数据库 session
team_id: 球队 ID
match_date: 比赛日期
as_of: 数据截止时间(用于回测防泄漏)
Returns:
InjuryQueryResult:包含查询记录与状态
- query_status="success": 查询成功(即使结果也为空)
- query_status="source_not_configured": API_FOOTBALL_KEY 未配置
- query_status="query_error": 查询异常
- query_status="no_local_data": Key 已配置但该队 injuries 表无任何历史记录
语义区分:
- success + 空结果 has_data=True(明确知道无人伤停)
- no_local_data has_data=False(本地尚未采集,需先 ingest)
- source_not_configured / query_error has_data=False(无法判断)
"""
from sqlalchemy import select, func
from src.db.models import Injury
# 检查 API 是否配置(只读配置,不发网络)
api_key = await get_runtime_value("API_FOOTBALL_KEY")
if not api_key:
logger.debug("API_FOOTBALL_KEY 未配置,跳过伤停查询 team=%s", team_id)
return InjuryQueryResult(records=[], query_status="source_not_configured")
try:
# Fix 3: 统一用 timezone-aware datetime 比较,禁止 date() 截断
if hasattr(match_date, "date") and callable(match_date.date):
match_date = match_date.date()
stmt = (
select(Injury)
.where(Injury.team_id == team_id)
.where(Injury.injury_date <= match_date)
.where(
(Injury.return_date.is_(None)) | (Injury.return_date >= match_date)
)
)
if as_of is not None:
# Fix 3: 统一用 date 比较,避免 timestamptz vs date 的时区问题
if hasattr(as_of, "date") and callable(as_of.date):
as_of = as_of.date()
stmt = stmt.where(func.date(Injury.retrieved_at) <= as_of)
result = await db.execute(stmt)
records = list(result.scalars().all())
# 判定「无本地数据」:该队从未有伤停记录
# 规则:该 team_id 在 injuries 表中 count==0
if not records:
count_stmt = select(func.count()).where(Injury.team_id == team_id)
team_count = (await db.execute(count_stmt)).scalar_one() or 0
if team_count == 0:
logger.debug("API Key 已配置但本地无伤停数据 team=%s,标记 no_local_data", team_id)
return InjuryQueryResult(records=[], query_status="no_local_data")
return InjuryQueryResult(records=records, query_status="success")
except Exception as e:
logger.exception("伤停查询异常 team=%s: %s", team_id, e)
return InjuryQueryResult(records=[], query_status="query_error")
+146
View File
@@ -0,0 +1,146 @@
"""API Key 轮换环:多 key 自动切换,遇到限流(429)自动跳过已冷却 key。
设计:
- 进程内纯内存状态(限速是短时状态,无需持久化;D7: worker 部署时各进程
独立计数不共享,上游限速额度应按 worker 数分摊,或前置网关统一管理)
- key 场景零开销:直接透传
- key 场景:429 时把当前 key 标记冷却(默认 60s),轮转到下一个可用 key
- 全部 key 都在冷却时:使用最早冷却的那个 key 并等待(退化到单 key 重试)
"""
from __future__ import annotations
import logging
import time
logger = logging.getLogger(__name__)
DEFAULT_COOLDOWN = 60.0 # 单个 key 被限流后的冷却时间(秒)
class KeyRing:
"""多 key 轮换环。在 async 单线程事件循环下无需加锁。"""
def __init__(self, keys: list[str], cooldown_seconds: float = DEFAULT_COOLDOWN) -> None:
self._keys: list[str] = [k.strip() for k in keys if k and k.strip()]
self._cooldown = cooldown_seconds
# key → 冷却过期时间戳(时刻);不在表中表示可用
self._blocked_until: dict[str, float] = {}
self._index = 0 # 当前轮转位置
@property
def has_multiple(self) -> bool:
return len(self._keys) > 1
@property
def all_keys(self) -> list[str]:
return list(self._keys)
@property
def active_key(self) -> str | None:
"""当前指向的 key(即使正在冷却也返回,用于上报)。"""
if not self._keys:
return None
return self._keys[self._index]
def get(self) -> str | None:
"""获取一个可用 key:优先选不在冷却中的;全部冷却则选最早过期的。"""
if not self._keys:
return None
if len(self._keys) == 1:
return self._keys[0]
now = time.monotonic()
n = len(self._keys)
# 从当前 index 开始找一圈,找一个可用的
for offset in range(n):
idx = (self._index + offset) % n
key = self._keys[idx]
expire = self._blocked_until.get(key, 0.0)
if now >= expire:
# 可用:把指针移到这里
self._index = idx
# 清理已过期的冷却记录
if key in self._blocked_until:
del self._blocked_until[key]
return key
# 全部在冷却中:选最早过期的那个,并等待到它过期
earliest_key = min(self._keys, key=lambda k: self._blocked_until.get(k, 0.0))
self._index = self._keys.index(earliest_key)
return earliest_key
def report_rate_limited(self, key: str | None = None) -> str | None:
"""上报某个 key 被限流(429)。默认是当前 key。返回切换后的新 key。"""
target = key or self.active_key
if target and len(self._keys) > 1:
until = time.monotonic() + self._cooldown
self._blocked_until[target] = until
logger.warning(
"bzzoiro key 被限流(429),冷却 %.0fs: %s", self._cooldown, _mask(target),
)
# 轮转到下一个(即使只有一个 key 也做一次 get,保持行为一致)
return self.get()
def wait_if_all_blocked(self) -> float:
"""如果所有 key 都在冷却中,返回需要等待的秒数;否则返回 0。"""
if len(self._keys) <= 1:
return 0.0
now = time.monotonic()
remaining = [self._blocked_until.get(k, 0.0) - now for k in self._keys]
if all(r > -0.001 for r in remaining) and any(r > 0.001 for r in remaining):
# 全部仍在冷却中
return max(remaining)
return 0.0
def stats(self) -> dict:
"""当前 key 环状态(用于管理后台展示)。"""
now = time.monotonic()
return {
"total": len(self._keys),
"keys": [
{
"masked": _mask(k),
"blocked_remaining": max(0.0, round(self._blocked_until.get(k, 0.0) - now, 1)),
}
for k in self._keys
],
"active_index": self._index,
}
def _mask(key: str) -> str:
"""脱敏:只显示前 4 位和后 4 位。"""
if len(key) <= 10:
return key[:2] + "***"
return key[:4] + "..." + key[-4:]
# ── 全局单例(进程级,按 base URL 隔离) ──────────────────────────
_RINGS: dict[str, KeyRing] = {}
def parse_keys(value: str | None) -> list[str]:
"""解析 key 配置值:支持逗号、分号、换行分隔的多个 key。"""
if not value:
return []
# 统一替换分隔符为逗号后拆分
normalized = value.replace("\n", ",").replace(";", ",")
return [k.strip() for k in normalized.split(",") if k.strip()]
def get_key_ring(base: str, raw_keys: str | None, cooldown_seconds: float = DEFAULT_COOLDOWN) -> KeyRing:
"""获取(或创建)某 base URL 对应的 KeyRing。"""
key = base
ring = _RINGS.get(key)
parsed = parse_keys(raw_keys)
if ring is None:
ring = KeyRing(parsed, cooldown_seconds)
_RINGS[key] = ring
else:
# 热更新 key 列表(增删 key 无需重启)
if set(ring.all_keys) != set(parsed):
ring._keys = parsed
ring._blocked_until.clear()
ring._index = 0
ring._cooldown = cooldown_seconds
return ring
+9 -33
View File
@@ -35,6 +35,8 @@ class NormalizedMatch:
home_team: str
away_team: str
match_status: str = "finished"
# P0-01:比分可信度。known=可靠比分;missing=完赛缺分;unknown=待定。
score_status: str = "unknown"
home_goals: int | None = None
away_goals: int | None = None
season_label: str = ""
@@ -217,37 +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")))
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
def normalize_understat(raw: dict, league_type: str) -> NormalizedMatch | None:
"""understat 单场 → NormalizedMatch(仅 xG)。"""
from src.data.team_names import normalize as normalize_name
dt_str = raw.get("datetime") or raw.get("date")
if not dt_str:
return None
dt = _parse_date(dt_str)
if dt is None:
return None
home_info = raw.get("h", {})
away_info = raw.get("a", {})
home_name = home_info.get("title", "") if isinstance(home_info, dict) else ""
away_name = away_info.get("title", "") if isinstance(away_info, dict) else ""
home = normalize_name(home_name)
away = normalize_name(away_name)
if not home or not away or home == away:
return None
home_xg = _to_float(raw["xG"].get("h")) if isinstance(raw.get("xG"), dict) else None
away_xg = _to_float(raw["xG"].get("a")) if isinstance(raw.get("xG"), dict) else None
return NormalizedMatch(
league_type=league_type,
date=dt,
home_team=home,
away_team=away,
match_status="finished",
season_label=derive_season_label(dt),
home_xg=home_xg,
away_xg=away_xg,
)
+80
View File
@@ -0,0 +1,80 @@
"""管线基础设施写入助手:RawEvent(Bronze 原始载荷)/ IngestFailure(死信)/ DataLineage(血缘)。
bzzoiro.py 拆出约定(与拆分前一致):
- add commit 事务由调用方 UnitOfWork 控制,分批事务约定不变;
- 死信与 Bronze 写入同为 best-effort:失败只记 warning,绝不拖垮采集主流程
"""
from __future__ import annotations
import logging
from src.db.models import DataLineage, IngestFailure, RawEvent
logger = logging.getLogger(__name__)
async def _write_raw_event(db, source_system: str, source_record_id: str, raw_payload: dict, batch_id: str | None = None) -> None:
"""写入 Bronze 层原始事件(幂等:同 source_record_id 跳过)。"""
from sqlalchemy import select as _select
stmt = _select(RawEvent).where(
RawEvent.source_system == source_system,
RawEvent.source_record_id == source_record_id,
)
existing = (await db.execute(stmt)).scalar_one_or_none()
if existing is None:
db.add(RawEvent(
source_system=source_system,
source_record_id=source_record_id,
raw_payload=raw_payload,
ingest_batch_id=batch_id,
))
async def _write_ingest_failure(db, source_system: str, entity_type: str, source_record_id: str | None, error_type: str, error_detail: str | None, raw_payload: dict | None = None) -> None:
"""写入采集失败死信。"""
db.add(IngestFailure(
source_system=source_system,
entity_type=entity_type,
source_record_id=source_record_id,
error_type=error_type,
error_detail=error_detail,
raw_payload=raw_payload,
))
async def _safe_write_ingest_failure(
db,
*,
entity_type: str,
source_record_id: str | None,
error: Exception,
raw_payload: dict | None = None,
) -> None:
"""抓取失败时尽力写入死信表(失败不影响主流程)。
死信是可观测性基础设施, RawEvent/Lineage 同级:写入失败只记
warning,绝不能让原始抓取错误之外的新异常打断采集循环
"""
try:
await _write_ingest_failure(
db, "bzzoiro", entity_type, source_record_id,
"fetch_error", str(error), raw_payload,
)
except Exception:
logger.warning(
"写入 ingest_failures 死信失败(entity=%s, record=%s): %s",
entity_type, source_record_id, error, exc_info=True,
)
async def _write_lineage(db, source_system: str, source_record_id: str, target_table: str, target_id: int | None, transform_name: str, transform_detail: dict | None = None, batch_id: str | None = None) -> None:
"""写入 ETL 血缘追踪。"""
db.add(DataLineage(
source_system=source_system,
source_record_id=source_record_id,
target_table=target_table,
target_id=target_id,
transform_name=transform_name,
transform_detail=transform_detail,
batch_id=batch_id,
))
+35 -13
View File
@@ -3,14 +3,18 @@
定义 DataSource 契约,并提供全局注册表供路由层分发
每个比赛数据源实现该协议,注册后即可通过统一入口调度
: injuries 是球员级独立领域( Injury ),不遵循此协议
当前只有 bzzoiro 一个数据源(Understat / injuries 已移除),
保留协议与注册表是为了统一 ingest 调度入口的结构
"""
from __future__ import annotations
import logging
from typing import Protocol
from src.db.base import AsyncSession
logger = logging.getLogger(__name__)
class DataSource(Protocol):
"""比赛数据源契约:抓取 → 规范化 → 入库。"""
@@ -28,6 +32,9 @@ class DataSource(Protocol):
# ── 注册表 ──
_SOURCES: dict[str, DataSource] = {}
# 延迟加载标记:保证 _load_sources() 最多执行一次,重复调用廉价
_loaded = False
def register(source):
"""装饰器:将数据源注册到全局注册表。
@@ -41,8 +48,7 @@ def register(source):
def get_source(name: str) -> DataSource:
"""按名获取数据源。"""
if not _SOURCES:
_load_sources()
_load_sources()
if name not in _SOURCES:
raise ValueError(f"未知数据源: {name}")
return _SOURCES[name]
@@ -50,19 +56,35 @@ def get_source(name: str) -> DataSource:
def list_sources() -> list[str]:
"""列出所有已注册数据源名。"""
if not _SOURCES:
_load_sources()
_load_sources()
return list(_SOURCES.keys())
def _load_sources() -> None:
"""延迟导入数据源触发 @register(避免循环导入)。"""
from src.data.bzzoiro import BzzoiroSource # noqa: F811
from src.data.understat import UnderstatSource # noqa: F811
"""延迟导入数据源触发 @register(避免循环导入)。
导入失败必须留下痕迹:静默吞掉 ImportError 会让注册表恒为空,
导致 get_source() 对所有数据源都报未知数据源,把导入错误
伪装成不存在的名字 这类静默失效极难定位,故在此显式记日志
失败时保持 _loaded=False:后续调用会重新尝试导入正常应用流程中
src.api.app 先导入本模块,此处的模块级预热是在 `src.data.bzzoiro`
尚未初始化时发起的,导入链在本模块内成环,首次必然失败(bzzoiro
仍在加载中),由后续 get_source() / list_sources() 调用完成真正的装载
"""
global _loaded
if _loaded:
return
try:
from src.data.bzzoiro import BzzoiroSource # noqa: F401
except Exception:
logger.exception(
"数据源模块导入失败(通常为初始化中途的环状导入),本次注册表为空;"
"下次 get_source()/list_sources() 调用会自动重试"
)
return # 保持 _loaded=False,下次调用可重试
_loaded = True
# 保持向后兼容:模块加载时尝试加载(但不再强制)
try:
_load_sources()
except Exception:
pass
# 模块加载时预热(失败会记录日志,不再静默)
_load_sources()
+17 -9
View File
@@ -17,11 +17,12 @@ TEAM_NAME_ZH: dict[str, str] = {
"Nottingham Forest": "诺丁汉森林", "AFC Bournemouth": "伯恩茅斯",
"Leeds United": "利兹联", "Leicester City": "莱斯特城",
"Ipswich Town": "伊普斯维奇", "Southampton": "南安普顿",
"Norwich City": "诺维奇", "Sheffield United": "谢菲尔德联",
"Norwich City": "诺维奇", "Norwich": "诺维奇",
"Sheffield United": "谢菲尔德联",
"Sheffield Wednesday": "谢周三", "Stoke City": "斯托克城",
"Sunderland": "桑德兰", "Burnley": "伯恩利", "Watford": "沃特福德",
"Hull City": "赫尔城", "Huddersfield Town": "哈德斯菲尔德",
"Luton Town": "卢顿", "Cardiff City": "加的夫", "Swansea City": "斯旺西",
"Luton Town": "卢顿", "Cardiff City": "加的夫", "Swansea City": "斯旺西",
"West Bromwich Albion": "西布罗姆维奇", "Birmingham City": "伯明翰",
"Blackburn Rovers": "布莱克本", "Bolton Wanderers": "博尔顿",
"Barnsley": "巴恩斯利", "Blackpool": "布莱克浦",
@@ -35,17 +36,19 @@ TEAM_NAME_ZH: dict[str, str] = {
"Celtic": "凯尔特人", "Rangers": "流浪者", "Aberdeen": "阿伯丁",
"Heart of Midlothian": "哈茨", "Hibernian": "希伯尼安",
"Derry City": "德里城", "Shelbourne": "谢尔本", "Larne FC": "拉恩",
"Linfield FC": "林斯菲尔德", "Shamrock Rovers": "沙姆罗克流浪",
"Linfield FC": "菲尔德", "Shamrock Rovers": "沙姆罗克流浪",
# ── 西班牙 ──
"Real Madrid": "皇家马德里", "FC Barcelona": "巴塞罗那",
"Atlético Madrid": "马德里竞技", "Athletic Club": "毕尔巴鄂竞技",
"Atlético Madrid": "马德里竞技", "Atletico Madrid": "马德里竞技",
"Athletic Club": "毕尔巴鄂竞技",
"Real Sociedad": "皇家社会", "Villarreal": "比利亚雷亚尔",
"Real Betis": "皇家贝蒂斯", "Sevilla": "塞维利亚", "Valencia": "瓦伦西亚",
"Celta Vigo": "塞尔塔", "Osasuna": "奥萨苏纳", "Getafe": "赫塔菲",
"Rayo Vallecano": "巴列卡诺", "Mallorca": "马略卡", "Girona FC": "赫罗纳",
"Girona": "赫罗纳", "Espanyol": "西班牙人", "UD Las Palmas": "拉斯帕尔马斯",
"Las Palmas": "拉斯帕尔马斯", "Deportivo Alavés": "阿拉维斯",
"Leganés": "莱加内斯", "Elche": "埃尔切", "Levante UD": "万特",
"Leganés": "莱加内斯", "Leganes": "加内斯",
"Elche": "埃尔切", "Levante UD": "莱万特",
"Malaga CF": "马拉加", "Deportivo de A Coruna": "拉科鲁尼亚",
"Real Oviedo": "皇家奥维耶多", "Real Racing Club": "桑坦德竞技",
"Real Valladolid": "巴利亚多利德",
@@ -59,20 +62,25 @@ TEAM_NAME_ZH: dict[str, str] = {
"Pisa": "比萨", "Cremonese": "克雷莫纳", "AC Monza": "蒙扎",
"Frosinone": "弗罗西诺内", "Sassuolo": "萨索洛",
# ── 德国 ──
"FC Bayern Munchen": "拜仁慕尼黑", "Borussia Dortmund": "多特蒙德",
"FC Bayern Munchen": "拜仁慕尼黑", "Bayern München": "拜仁慕尼黑",
"Borussia Dortmund": "多特蒙德",
"Bayer 04 Leverkusen": "勒沃库森", "RB Leipzig": "莱比锡红牛",
"Borussia Mönchengladbach": "门兴格拉德巴赫", "VfB Stuttgart": "斯图加特",
"Eintracht Frankfurt": "法兰克福", "VfL Wolfsburg": "沃尔夫斯堡",
"SC Freiburg": "弗赖堡", "TSG Hoffenheim": "霍芬海姆",
"1. FC Union Berlin": "柏林联合", "1. FC Koln": "科隆",
"1. FSV Mainz 05": "美因茨", "FC Augsburg": "奥格斯堡",
"SV Werder Bremen": "云达不来梅", "VfL Bochum 1848": "波鸿",
"1. FC Union Berlin": "柏林联合", "Union Berlin": "柏林联合",
"1. FC Koln": "科隆", "FC Köln": "科隆",
"1. FSV Mainz 05": "美因茨", "Mainz 05": "美因茨",
"FC Augsburg": "奥格斯堡",
"SV Werder Bremen": "云达不来梅", "Werder Bremen": "云达不来梅",
"VfL Bochum 1848": "波鸿", "VfL Bochum": "波鸿",
"1. FC Heidenheim": "海登海姆", "FC St. Pauli": "圣保利",
"Holstein Kiel": "荷尔斯泰因基尔", "FC Schalke 04": "沙尔克04",
"Hamburger SV": "汉堡", "SC Paderborn 07": "帕德博恩",
"SV 07 Elversberg": "埃弗斯贝格",
# ── 法国 ──
"Paris Saint-Germain": "巴黎圣日耳曼", "Olympique de Marseille": "马赛",
"Olympique Marseille": "马赛",
"Olympique Lyonnais": "里昂", "AS Monaco": "摩纳哥", "Lille OSC": "里尔",
"OGC Nice": "尼斯", "RC Lens": "朗斯", "Stade Rennais": "雷恩",
"RC Strasbourg": "斯特拉斯堡", "Stade Brestois": "布雷斯特",
-223
View File
@@ -1,223 +0,0 @@
"""Understat xG 数据源。
迁移自旧项目 app/data/sources/understat.py,改成 async
使用 Repository 模式进行数据访问,不直接控制事务
"""
from __future__ import annotations
import asyncio
import json
import logging
import random
import re
from datetime import datetime, timedelta, timezone
import httpx
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from src.core.http_client import get_client
from src.data.config import FDCO_TO_UNDERSTAT, LEAGUE_NAMES
from src.data.normalize import normalize_understat
from src.data.sources import register
from src.db.models import League, Match, MatchStats, Team
logger = logging.getLogger(__name__)
UNDERSTAT_BASE = "https://understat.com/getLeagueData/{league}/{season}"
async def fetch_understat(league_code: str, season: int) -> list[dict]:
"""抓取 understat 单赛季 xG 数据。
Args:
league_code: fdco 风格代码, 'E0'
season: 赛季起始年, 2025 表示 2025-2026 赛季
Returns:
比赛数组,每项含 datetime/h/a/xG
"""
understat_league = FDCO_TO_UNDERSTAT.get(league_code)
if understat_league is None:
raise ValueError(f"未知联赛代码: {league_code}")
url = UNDERSTAT_BASE.format(league=understat_league, season=season)
headers = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36",
"X-Requested-With": "XMLHttpRequest",
"Referer": f"https://understat.com/league/{understat_league}/{season}",
}
# 重试:网络错误 / 5xx / 429
last_exc: Exception | None = None
for attempt in range(3):
try:
client = get_client()
resp = await asyncio.wait_for(
client.get(
url, headers=headers,
timeout=httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0),
),
timeout=60.0,
)
resp.raise_for_status()
break
except Exception as e:
last_exc = e
if attempt == 2:
raise
delay = min(2 ** attempt, 8) + random.uniform(0, 1)
logger.warning("understat fetch failed, retry %d in %.1fs: %s", attempt + 1, delay, e)
await asyncio.sleep(delay)
else:
raise RuntimeError(f"understat fetch failed: {last_exc}")
# 优先按 JSON 响应解析(getLeagueData 接口返回 {teams, players, dates})
try:
data = resp.json()
except Exception:
data = None
if isinstance(data, dict) and isinstance(data.get("dates"), list):
return data["dates"]
# 兼容旧版联赛页面:内嵌 var datesData = JSON.parse('...')
text = resp.text
match = re.search(r"var\s+datesData\s*=\s*JSON\.parse\('([^']+)'\)", text)
if not match:
logger.warning("understat 响应格式不符: %s...", text[:200])
return []
decoded = match.group(1).encode().decode("unicode_escape")
data = json.loads(decoded)
return data
def _match_key(home_team_id: int, away_team_id: int, match_date) -> tuple[int, int, str]:
"""比赛去重键:(主队, 客队, 天级日期 ISO 字符串)。
统一在这里构造,避免"预加载时用 str(date)、写入时用 isoformat()"这类
隐式格式依赖 两者当前恰好相等,但一旦有人改动其一就会静默失配,
导致所有比赛被判为不存在而重复插入
"""
if hasattr(match_date, "date") and callable(match_date.date):
match_date = match_date.date()
return (home_team_id, away_team_id, match_date.isoformat() if match_date is not None else "")
@register
class UnderstatSource:
"""understat xG 数据源(实现 DataSource 协议)。"""
name = "understat"
async def ingest(self, db, *, league: str, season: int) -> dict:
"""采集 understat xG → 回填到现有 Match。只回填 xG 字段,不创建新 Match。
注意: 本方法不控制事务(commit/rollback),由调用方通过 UnitOfWork 控制
P1-3: 批量查询优化,将单赛季 380 × 3 DB 往返降为 3 次查询
"""
from src.db.repositories import LeagueRepository, TeamRepository
result = {"updated": 0, "skipped": 0, "unmatched": 0, "errors": []}
try:
raw_matches = await fetch_understat(league, season)
except Exception as e:
logger.exception("understat fetch failed for %s %s", league, season)
result["errors"].append(f"fetch failed: {e}")
return result
# 使用 Repository
league_repo = LeagueRepository(db)
team_repo = TeamRepository(db)
# 查联赛
league_obj = await league_repo.get_by_code(league)
if league_obj is None:
result["errors"].append(f"league {league} not found in DB")
return result
# === 批量优化: 一次规范化,收集球队名和日期 ===
normalized_matches: list = []
all_team_names: set[str] = set()
for raw in raw_matches:
if not raw.get("isResult"):
continue
try:
nm = normalize_understat(raw, league)
if nm is None:
result["skipped"] += 1
continue
except Exception as e:
result["errors"].append(f"normalize: {e}")
continue
normalized_matches.append((nm, raw))
all_team_names.add(nm.home_team)
all_team_names.add(nm.away_team)
if not normalized_matches:
return result
# === 批量查询球队(1 次 DB 往返) ===
team_name_to_id = {}
if all_team_names:
teams = await team_repo.get_all_by_names(list(all_team_names))
team_name_to_id = {name: team.id for name, team in teams.items()}
# === 批量查询已有比赛(1 次 DB 往返,按日期范围) ===
match_dict: dict[tuple, Match] = {}
dates = [nm.date for nm, _ in normalized_matches if nm.date is not None]
if dates:
min_dt = min(dates) - timedelta(days=30)
max_dt = max(dates) + timedelta(days=30)
stmt = (
select(Match)
.options(selectinload(Match.stats))
.where(Match.league_id == league_obj.id)
.where(Match.match_date >= min_dt)
.where(Match.match_date <= max_dt)
)
for m in (await db.execute(stmt)).scalars():
key = _match_key(m.home_team_id, m.away_team_id, m.match_date_date)
match_dict[key] = m
# === 内存匹配 + 回填 xG ===
for nm, raw in normalized_matches:
home_team_id = team_name_to_id.get(nm.home_team)
away_team_id = team_name_to_id.get(nm.away_team)
if home_team_id is None or away_team_id is None:
result["unmatched"] += 1
continue
match_key = _match_key(home_team_id, away_team_id, nm.date)
existing = match_dict.get(match_key)
if existing is None:
result["unmatched"] += 1
continue
# 回填 xG
if existing.stats is None and (nm.home_xg is not None or nm.away_xg is not None):
now = datetime.now(timezone.utc)
# available_at 语义:统计「可被使用」的最早时间,至少不早于比赛结束
# 近似:开球 + 2 小时(实际完赛时间约为 +2 小时,非官方公布时间)
match_date = existing.match_date if existing.match_date else now
available_at = match_date + timedelta(hours=2)
existing.stats = MatchStats(
match_id=existing.id,
source="understat",
source_record_id=str(raw.get("id", "")),
retrieved_at=now,
available_at=available_at,
)
db.add(existing.stats)
await db.flush()
if existing.stats is not None:
if existing.stats.home_xg is None and nm.home_xg is not None:
existing.stats.home_xg = nm.home_xg
result["updated"] += 1
if existing.stats.away_xg is None and nm.away_xg is not None:
existing.stats.away_xg = nm.away_xg
# 注意: 不在此处 commit,由调用方 UnitOfWork 控制事务
return result
-19
View File
@@ -68,25 +68,6 @@ async def short_read():
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:
"""验证数据库连接(不建表)。
+170 -43
View File
@@ -1,4 +1,7 @@
"""6 张表 ORM: leagues / teams / matches / match_stats / predictions / injuries。"""
"""ORM 模型: leagues / teams / matches / match_stats / standings / predictions。
数据源统一为 bzzoiro(单一数据源),伤停(injuries) Understat 已移除
"""
from __future__ import annotations
from datetime import date, datetime, timezone
@@ -16,7 +19,7 @@ from sqlalchemy import (
String,
Text,
UniqueConstraint,
and_,
column,
func,
)
from sqlalchemy.dialects.postgresql import JSONB
@@ -54,6 +57,22 @@ class Team(Base):
away_matches: Mapped[list["Match"]] = relationship(foreign_keys="Match.away_team_id", back_populates="away_team")
class TeamAlias(Base):
"""球队别名:同一球队的不同写法(大小写/译名/缩写)映射到归一后的 teams.id。
入库流程(get_or_create):normalize(name) teams.name team_aliases
都没有再 insert Team别名不自动合并历史重复队,需显式添加
alias_normalized normalize(别名)后的稳定幂等键,用作 PK 避免重复插入
"""
__tablename__ = "team_aliases"
# normalize(别名)后的值,稳定幂等,用作主键
alias_normalized: Mapped[str] = mapped_column(String(120), primary_key=True)
team_id: Mapped[int] = mapped_column(ForeignKey("teams.id", ondelete="CASCADE"), nullable=False)
original_alias: Mapped[str] = mapped_column(String(120), nullable=False) # 原始写法(保留供参考)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
class Match(Base):
__tablename__ = "matches"
@@ -70,11 +89,16 @@ class Match(Base):
index=True,
)
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)
away_goals: Mapped[int | None] = mapped_column(Integer)
home_ht_goals: Mapped[int | None] = mapped_column(Integer)
away_ht_goals: Mapped[int | None] = mapped_column(Integer)
match_stage: Mapped[str | None] = mapped_column(String(100))
# 数据血缘:bzzoiro 上游事件 ID,用于 /events/{id}/stats/ 统计回填
source_event_id: Mapped[int | None] = mapped_column(BigInteger, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
@@ -90,7 +114,7 @@ class Match(Base):
foreign_keys=[away_team_id], back_populates="away_matches", lazy="selectin"
)
stats: Mapped["MatchStats | None"] = relationship(
back_populates="match", cascade="all, delete-orphan", lazy="selectin"
back_populates="match", cascade="all, delete-orphan", lazy="select"
)
predictions: Mapped[list["Prediction"]] = relationship(back_populates="match", cascade="all, delete-orphan")
@@ -107,6 +131,33 @@ class Match(Base):
"match_date_date",
unique=True,
),
# P0-01:比分可信度约束(替代原 ck_matches_finished_has_score):
# - score_status=known → 必须有比分(非 NULL)
# - score_status=missing → 必须 NULL(完赛缺分,禁止伪造 0:0)
# - score_status=unknown → 必须 NULL
CheckConstraint(
"score_status IN ('known', 'missing', 'unknown')",
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(
"match_status IN ('finished', 'scheduled', 'in_play', 'paused', 'postponed', 'cancelled', 'suspended')",
name="ck_matches_status_enum",
),
# 半场进球 ≤ 全场进球
CheckConstraint(
"home_ht_goals IS NULL OR home_goals IS NULL OR home_ht_goals <= home_goals",
name="ck_matches_home_ht_le_full",
),
CheckConstraint(
"away_ht_goals IS NULL OR away_goals IS NULL OR away_ht_goals <= away_goals",
name="ck_matches_away_ht_le_full",
),
)
@@ -127,6 +178,11 @@ class MatchStats(Base):
away_yellow_cards: Mapped[int | None] = mapped_column(Integer)
home_red_cards: Mapped[int | None] = mapped_column(Integer)
away_red_cards: Mapped[int | None] = mapped_column(Integer)
# bzzoiro /events/{id}/stats/ 扩展字段
home_big_chances: Mapped[int | None] = mapped_column(Integer)
away_big_chances: Mapped[int | None] = mapped_column(Integer)
home_fouls: Mapped[int | None] = mapped_column(Integer)
away_fouls: Mapped[int | None] = mapped_column(Integer)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
# 数据血缘:追踪统计数据的来源和可用时间
source: Mapped[str | None] = mapped_column(String(30)) # bzzoiro / understat
@@ -146,39 +202,47 @@ class MatchStats(Base):
)
class Injury(Base):
"""球员伤停记录(api-football 数据源)。"""
__tablename__ = "injuries"
class Standing(Base):
"""联赛积分榜快照(bzzoiro /leagues/{id}/standings/)。
P0-02: 改为追加快照(append-only)每次采集 INSERT 新行,available_at=now;
查询取 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"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
player_id: Mapped[int | None] = mapped_column(Integer, index=True)
player_name: Mapped[str] = mapped_column(String(120), nullable=False)
team_id: Mapped[int | None] = mapped_column(ForeignKey("teams.id"), index=True)
fixture_id: Mapped[int | None] = mapped_column(Integer)
league_id: Mapped[int | None] = mapped_column(Integer)
injury_type: Mapped[str | None] = mapped_column(String(50)) # Missing Fixture / Suspended
reason: Mapped[str | None] = mapped_column(String(200))
injury_date: Mapped[date | None] = mapped_column(Date, index=True)
return_date: Mapped[date | None] = mapped_column(Date)
league_id: Mapped[int] = mapped_column(ForeignKey("leagues.id"), nullable=False)
season: Mapped[str] = mapped_column(String(12), nullable=False)
team_id: Mapped[int] = mapped_column(ForeignKey("teams.id"), nullable=False)
position: Mapped[int] = mapped_column(Integer, nullable=False)
played: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
won: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
drawn: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
lost: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
goals_for: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
goals_against: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
goal_diff: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
points: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
xg_for: Mapped[float | None] = mapped_column(Float)
xg_against: Mapped[float | None] = mapped_column(Float)
form: Mapped[str | None] = mapped_column(String(20)) # 近期赛果串,如 "WWDLW"
zone: Mapped[str | None] = mapped_column(String(50)) # champions_league / relegation 等
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
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)
team: Mapped["Team | None"] = relationship()
league: Mapped[League] = relationship()
team: Mapped[Team] = relationship(lazy="selectin")
__table_args__ = (
# Fix 4: partial unique index — 只在 player_id 和 fixture_id 都非空时强制唯一
# PostgreSQL 中 NULL != NULL,普通唯一索引无法防止 NULL 重复
Index(
"ix_injuries_player_fixture",
"player_id",
"fixture_id",
"injury_type",
unique=True,
postgresql_where=and_(
player_id.is_not(None),
fixture_id.is_not(None),
),
),
Index("ix_injuries_team_date", "team_id", "injury_date"),
# 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_team_available", "league_id", "season", "team_id", "available_at"),
)
@@ -186,7 +250,7 @@ class Prediction(Base):
__tablename__ = "predictions"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
match_id: Mapped[int] = mapped_column(ForeignKey("matches.id"), nullable=False)
match_id: Mapped[int] = mapped_column(ForeignKey("matches.id", ondelete="CASCADE"), nullable=False)
provider: Mapped[str] = mapped_column(String(30), nullable=False)
model: Mapped[str] = mapped_column(String(80), nullable=False)
prompt_version: Mapped[str] = mapped_column(String(20), nullable=False, default="v1")
@@ -204,7 +268,9 @@ class Prediction(Base):
raw_response: Mapped[dict | None] = mapped_column(JSONB)
# multi-agent 模式: 各专家报告
mode: Mapped[str] = mapped_column(String(20), nullable=False, default="single")
agent_outputs: Mapped[dict | None] = mapped_column(JSONB)
# D5: multi-agent 模式存各专家报告列表(list[dict]);历史数据/兼容路径可能存 dict。
# 仅修正类型标注与真实 JSON 形状一致,列类型(JSONB)与数据不变。
agent_outputs: Mapped[list[dict] | dict | None] = mapped_column(JSONB)
# Fix: agent_weights 独立持久化到列(原本只在 raw_response 中)
agent_weights: Mapped[dict | None] = mapped_column(JSONB)
# 预测状态: success / failed / degraded
@@ -224,14 +290,13 @@ class Prediction(Base):
match: Mapped[Match] = relationship(back_populates="predictions")
__table_args__ = (
# Fix: 唯一约束增加 mode + run_type,允许 live 与 backtest 共存
# 防止回测覆盖未结算的实盘预测(后续 settle 会污染评估数据)
UniqueConstraint(
"match_id", "provider", "model", "mode", "run_type",
name="uq_predictions_match_provider_model_mode_run_type",
# P0-03: 幂等指纹——input_hash 非空时唯一(同指纹→返回已有行,不 UPDATE/INSERT);
# 兼容旧数据 NULL input_hash(不强制回填)。
Index(
"ix_predictions_input_hash_unique", "input_hash", unique=True,
postgresql_where=column("input_hash").isnot(None),
),
Index("ix_predictions_match", "match_id"),
Index("ix_predictions_provider_model", "provider", "model"),
# 数据截止时间过滤查询用(按 prediction_cutoff_at 取「赛前已生成」的预测)
Index("ix_predictions_cutoff_at", "prediction_cutoff_at"),
# 数据库级约束:最后一道防线
@@ -239,7 +304,7 @@ class Prediction(Base):
CheckConstraint("pred_away_goals >= 0", name="ck_pred_away_goals_nonneg"),
CheckConstraint("subjective_confidence >= 0 AND subjective_confidence <= 1", name="ck_confidence_range"),
CheckConstraint("pred_1x2 IN ('1', 'X', '2')", name="ck_pred_1x2_enum"),
CheckConstraint("mode IN ('single', 'multi')", name="ck_mode_enum"),
CheckConstraint("mode IN ('single', 'multi', 'baseline')", name="ck_mode_enum"),
CheckConstraint("status IN ('success', 'failed', 'degraded')", name="ck_status_enum"),
CheckConstraint("run_type IN ('live', 'backtest')", name="ck_run_type_enum"),
)
@@ -254,12 +319,31 @@ class AppSetting(Base):
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
class Schedule(Base):
"""定时采集任务配置。"""
__tablename__ = "schedules"
id: Mapped[str] = mapped_column(String(50), primary_key=True)
task: Mapped[str] = mapped_column(String(20), nullable=False) # events / standings / stats / all
cron: Mapped[str] = mapped_column(String(100), nullable=False) # cron 表达式
leagues: Mapped[str | None] = mapped_column(Text) # 逗号分隔的联赛代码,空=全部
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
last_run_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_status: Mapped[str | None] = mapped_column(String(20)) # success / failed
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
# ── 数据管线基础设施(对应迁移 0008) ──────────────────────────────────
# Bronze 层、死信、质量监控、血缘追踪 4 张表。
class RawEvent(Base):
"""Bronze 层:采集到的原始事件存档,便于重放与审计。"""
"""Bronze 层:采集到的原始事件存档,便于重放与审计。
预留未启用:当前 bzzoiro 管线不写入此表
未来接线计划:events 采集成功后写入 raw_payload,支持重放与审计
"""
__tablename__ = "raw_events"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
@@ -275,8 +359,43 @@ class RawEvent(Base):
)
class IngestJob(Base):
"""采集任务状态:跟踪每次触后台采集任务的执行进度与结果。
POST /api/v1/ingest/bzzoiro 触发时写入(pendingrunningsuccess/failed),
前端 Collection 页据此轮询到终态,替代此前"30 秒后盲标完成"的模拟
分批 get_uow / BzzoiroSource / IngestFailure / Bronze/Lineage 均不受影响
(本表仅作状态追踪,不介入采集事务)
"""
__tablename__ = "ingest_jobs"
id: Mapped[str] = mapped_column(String(36), primary_key=True) # uuid4
task: Mapped[str] = mapped_column(String(20), nullable=False)
params: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default="pending")
result: Mapped[dict | None] = mapped_column(JSONB)
error: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
__table_args__ = (
Index("ix_ingest_job_status_created", "status", "created_at"),
CheckConstraint(
"status IN ('pending', 'running', 'success', 'failed')",
name="ck_ingest_job_status",
),
)
class IngestFailure(Base):
"""采集失败死信:记录失败原因、重试次数与下次重试时间。"""
"""采集失败死信:记录失败原因、重试次数与下次重试时间。
bzzoiro 三条管线(events / standings / stats)抓取失败时经由
bzzoiro._safe_write_ingest_failure 写入本表(尽力而为,写入失败
不影响采集主流程)admin 可通过 /admin/schedules/ingest-failures
查看与重试
"""
__tablename__ = "ingest_failures"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
@@ -302,7 +421,11 @@ class IngestFailure(Base):
class DataQualityCheck(Base):
"""数据质量监控:记录每次质量检查的结果。"""
"""数据质量监控:记录每次质量检查的结果。
预留未启用:当前 bzzoiro 管线不写入此表
未来接线计划:定时检查比赛/统计/积分榜完整性,写入检查结果
"""
__tablename__ = "data_quality_checks"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
@@ -323,7 +446,11 @@ class DataQualityCheck(Base):
class DataLineage(Base):
"""ETL 血缘追踪:记录从源到目标的转换过程。"""
"""ETL 血缘追踪:记录从源到目标的转换过程。
预留未启用:当前 bzzoiro 管线不写入此表
未来接线计划:每次采集写入 source_record_id target_table/id 映射
"""
__tablename__ = "data_lineage"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
+101 -22
View File
@@ -5,6 +5,10 @@ Repository 只负责查询,不负责事务提交。
"""
from __future__ import annotations
import logging
logger = logging.getLogger(__name__)
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.orm import selectinload
@@ -40,7 +44,7 @@ class MatchRepository:
) -> Match | None:
"""按联赛+主队+客队+日期查找比赛(天级匹配)。
预加载 stats:调用方(understat 回填)会读取 existing.stats,
预加载 stats:调用方(统计回填)会读取 existing.stats,
async session 下惰性加载会抛 MissingGreenlet
P2-3: 使用 match_date_date(已建索引)做等值匹配,避免 func.date()
@@ -64,6 +68,48 @@ class MatchRepository:
)
return (await self._session.execute(stmt)).scalar_one_or_none()
async def find_by_league_and_date_range(
self, league_id: int, start, end
) -> list[Match]:
"""批量预加载某联赛日期范围内的比赛(ingest 管线内存去重用)。"""
stmt = (
select(Match)
.where(Match.league_id == league_id)
.where(Match.match_date >= start)
.where(Match.match_date <= end)
)
return (await self._session.execute(stmt)).scalars().all()
async def find_by_source_event_id(self, source_event_id: int) -> Match | None:
"""按上游 event id 查找比赛(唯一命中,用于 upsert 优先路径)。
source_event_id 上有 partial unique 索引(WHERE IS NOT NULL),
同联赛同主客同天(自然键)与上游 event_id 共同保障同一场比赛
重复采集时 upsert 而非插入重复行
"""
stmt = (
select(Match)
.options(selectinload(Match.stats))
.where(Match.source_event_id == source_event_id)
)
return (await self._session.execute(stmt)).scalar_one_or_none()
async def find_finished_with_stats(self, league_ids: list[int], *, limit: int) -> list[Match]:
"""已完赛且有上游 event id 的比赛(按日期倒序),供统计回填逐场拉取。
预加载 stats:调用方需读取 existing.stats 判断是否跳过
"""
stmt = (
select(Match)
.options(selectinload(Match.stats))
.where(Match.match_status == "finished")
.where(Match.source_event_id.is_not(None))
.where(Match.league_id.in_(league_ids))
.order_by(Match.match_date.desc())
.limit(limit)
)
return (await self._session.execute(stmt)).scalars().all()
async def add(self, match: Match) -> None:
self._session.add(match)
await self._session.flush()
@@ -79,15 +125,62 @@ class TeamRepository:
stmt = select(Team).where(Team.name == name)
return (await self._session.execute(stmt)).scalar_one_or_none()
async def get_or_create(self, name: str) -> Team:
"""按名获取球队,不存在则创建。"""
team = await self.get_by_name(name)
if team is None:
team = Team(name=name)
self._session.add(team)
await self._session.flush()
async def get_or_create(self, name: str, *, name_zh: str | None = None) -> Team:
"""按名获取球队,不存在则创建(name_zh 供 bzzoiro 管线写中文名)
归一化咽喉 + 别名查找,三步定位:
1) normalize(name) teams.name
2) team_aliases( normalize(name) 为幂等键) 复用已映射的 teams.id
3) 都没有 insert Team(归一名)
创建新 Team info 打出原始名与归一后的规范名,便于排查重名
不自动合并历史重复队;需显式添加别名
"""
from src.data.team_names import normalize as normalize_name
from src.db.models import TeamAlias
normalized = normalize_name(name) or name.strip()
# 1) 归一名直查 teams
team = await self.get_by_name(normalized)
if team is not None:
return team
# 2) 别名查找:normalize(别名) 作为幂等键,命中即复用已有 Team
alias = await self._session.get(TeamAlias, normalized)
if alias is not None:
team = await self._session.get(Team, alias.team_id)
if team is not None:
logger.info("Team 别名命中: %s -> %s(已有 id=%s)", name, normalized, team.id)
return team
# 3) 新建 Team(归一名)
logger.info("创建新 Team: %s -> %s", name, normalized)
team = Team(name=normalized, name_zh=name_zh)
self._session.add(team)
await self._session.flush()
return team
async def add_alias(self, alias: str, team_id: int) -> TeamAlias:
"""为已有 Team 添加别名。
幂等: normalize(alias) PK,重复添加同一别名会 upsert
不自动合并历史重复队,仅建立别名映射
"""
from src.data.team_names import normalize as normalize_name
from src.db.models import TeamAlias
normalized = normalize_name(alias) or alias.strip()
existing = await self._session.get(TeamAlias, normalized)
if existing is not None:
existing.team_id = team_id # 允许重新指向
existing.original_alias = alias
await self._session.flush()
return existing
row = TeamAlias(alias_normalized=normalized, team_id=team_id, original_alias=alias)
self._session.add(row)
await self._session.flush()
return row
async def get_all_by_names(self, names: list[str]) -> dict[str, Team]:
"""批量获取球队,返回 name → Team 映射。"""
if not names:
@@ -122,17 +215,3 @@ class LeagueRepository:
async def add(self, league: League) -> None:
self._session.add(league)
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()
+1 -1
View File
@@ -35,7 +35,7 @@ def load_agent_prompt(name: str, version: str = "v1") -> str:
@dataclass
class AgentSpec:
"""领域专家 agent 定义。"""
name: str # h2h / form / home_away / injuries / stats
name: str # h2h / form / home_away / standings / stats
system_prompt: str # system message
slice_fn: object # async (header, before) -> str 切片函数
+66 -57
View File
@@ -6,14 +6,13 @@ import hashlib
import json
import logging
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from src.core.config import settings
from src.db.base import AsyncSessionLocal
from src.db.models import Match, Prediction
from src.db.unit_of_work import get_uow
from src.llm.predict import _upsert_prediction
from src.llm.predict import PredictResult, _insert_or_find_by_fingerprint
from src.llm.agents.base import AgentReport, AgentSpec, load_agent_prompt
from src.llm.context_builder import (
MatchHeader,
@@ -21,8 +20,8 @@ from src.llm.context_builder import (
h2h_slice,
header_text,
home_away_slice,
injuries_slice,
load_match_header,
standings_slice,
stats_slice,
)
from src.core.runtime_config import get_runtime_value
@@ -36,7 +35,7 @@ _AGENT_PROVIDER_CACHE_TTL = 60.0
# ── 5 个专家 agent 定义 ──
# A=近期状态 B=攻防数据 C=主客因素 D=阵容完整性 E=历史交锋
# A=近期状态 B=攻防数据 C=主客因素 D=联赛排名 E=历史交锋
SPECIALIST_SPECS: list[AgentSpec] = [
AgentSpec(
name="form",
@@ -54,9 +53,9 @@ SPECIALIST_SPECS: list[AgentSpec] = [
slice_fn=home_away_slice,
),
AgentSpec(
name="injuries",
system_prompt="你是足球阵容完整性分析专家。汇总伤停与停赛名单,输出战力缺失程度。只输出 JSON。",
slice_fn=injuries_slice,
name="standings",
system_prompt="你是足球联赛排名分析专家。分析积分榜位置、积分走势与分区,评估两队整体实力差距。只输出 JSON。",
slice_fn=standings_slice,
),
AgentSpec(
name="h2h",
@@ -76,50 +75,37 @@ AGENT_LABELS_ZH: dict[str, str] = {
"form": "近期状态分析专家",
"stats": "攻防数据分析专家",
"home_away": "主客因素分析专家",
"injuries": "阵容完整性分析专家",
"standings": "联赛排名分析专家",
"h2h": "历史交锋分析专家",
}
@dataclass
class MultiPredictResult:
prediction_id: int
provider: str
model: str
prompt_version: str
mode: str
pred_home_goals: float | None
pred_away_goals: float | None
alt_pred_home_goals: int | None
alt_pred_away_goals: int | None
pred_1x2: str | None
subjective_confidence: float | None
reasoning: str | None
context: str
agent_outputs: list[dict]
agent_weights: dict | None
status: str = "success"
latency_ms: int | None = None
prompt_tokens: int | None = None
completion_tokens: int | None = None
raw: dict | None = None
# D2(工程债): multi 结果类型与 single 统一 —— 扩展后的 PredictResult 用可选
# 字段(agent_outputs/agent_weights/prompt_tokens/completion_tokens/mode)承载
# 全部模式,此处仅保留别名。保留 `MultiPredictResult` 名字的原因:
# 1. predict_match_multi 签名 `-> MultiPredictResult:` 是 R4 源码守卫的标记;
# 2. src/llm/agents/__init__.py 对外 re-export 该名字。
MultiPredictResult = PredictResult
async def _agent_provider(agent_id: str, *, tier: str) -> LLMProvider:
async def _agent_provider(agent_id: str, *, tier: str, model_override: str | None = None) -> LLMProvider:
"""构造某 agent 专属 provider。
覆盖优先级:
模型: AGENT_MODEL_{ID}(运行时) 层级默认(LLM_SPECIALIST/AGGREGATOR_MODEL) 全局 LLM_MODEL
模型: model_override(调用方显式指定) AGENT_MODEL_{ID}(运行时) 层级默认(LLM_SPECIALIST/AGGREGATOR_MODEL) 全局 LLM_MODEL
地址/密钥: AGENT_BASE_URL_{ID} / AGENT_API_KEY_{ID}(运行时) 全局 LLM_BASE_URL / LLM_API_KEY
P3-2: 结果缓存 60 ,避免每次预测都多次查询运行时配置 DB
注意:model_override 生效时跳过缓存读写 否则带 override 的结果会泄漏给
不带 override 的调用(反之亦然),导致跨调用的模型串味
"""
cache_key = f"{agent_id}:{tier}"
cached = _AGENT_PROVIDER_CACHE.get(cache_key)
if cached is not None:
ts, provider = cached
if time.time() - ts < _AGENT_PROVIDER_CACHE_TTL:
return provider
if model_override is None:
cached = _AGENT_PROVIDER_CACHE.get(cache_key)
if cached is not None:
ts, provider = cached
if time.time() - ts < _AGENT_PROVIDER_CACHE_TTL:
return provider
pfx = f"AGENT_{agent_id.upper()}_"
p = await get_default_provider()
@@ -129,6 +115,9 @@ async def _agent_provider(agent_id: str, *, tier: str) -> LLMProvider:
model = await get_runtime_value(f"{pfx}MODEL")
if model:
p.model = model
# 调用方显式传入的 model 优先级最高,高于 agent 级与层级默认
if model_override:
p.model = model_override
base = await get_runtime_value(f"{pfx}BASE_URL")
if base:
p.base_url = base
@@ -136,10 +125,11 @@ async def _agent_provider(agent_id: str, *, tier: str) -> LLMProvider:
if key:
p.api_key = key
_AGENT_PROVIDER_CACHE[cache_key] = (time.time(), p)
# 简单淘汰:超过 20 条时清空(60s TTL 下不会累积太多)
if len(_AGENT_PROVIDER_CACHE) > 20:
_AGENT_PROVIDER_CACHE.clear()
if model_override is None:
_AGENT_PROVIDER_CACHE[cache_key] = (time.time(), p)
# 简单淘汰:超过 20 条时清空(60s TTL 下不会累积太多)
if len(_AGENT_PROVIDER_CACHE) > 20:
_AGENT_PROVIDER_CACHE.clear()
return p
@@ -148,13 +138,19 @@ async def run_specialists(
*,
version: str = "v1",
before=None,
model_override: str | None = None,
) -> list[AgentReport]:
"""并行执行 5 个专家 agent。fail-open: 单个失败不影响其他。
before: 数据截止时间(回测防泄漏)None 表示不限制
model_override: 调用方显式指定的模型,覆盖各 agent 的层级默认
注意:model_override 必须作为形参下传,不能用模块级变量中转
backtest asyncio.gather 并发 8 场预测( backtest.py Semaphore(8)),
模块级变量会被并发调用互相覆盖,导致 A 场的预测用上 B 场的模型
"""
tasks = [
_run_one(spec, header, await _agent_provider(spec.name, tier="specialist"), version=version, before=before)
_run_one(spec, header, await _agent_provider(spec.name, tier="specialist", model_override=model_override), version=version, before=before)
for spec in SPECIALIST_SPECS
]
results = await asyncio.gather(*tasks, return_exceptions=True)
@@ -222,11 +218,13 @@ async def predict_match_multi(
version: str = "v1",
backtest: bool = False,
cutoff_at=None,
model: str | None = None,
) -> MultiPredictResult:
"""多 agent 端到端预测: 切片 → 并行专家 → 终裁 → 存库。
backtest: 回测模式True cutoff 自动设为 match_dt - 1
cutoff_at: 显式截止时间(优先于 backtest 自动计算)
model: 显式指定模型,优先于 agent /层级默认配置(single 模式语义一致)
"""
start = time.perf_counter()
@@ -247,7 +245,11 @@ async def predict_match_multi(
prediction_cutoff_at = cutoff
# 2. 并行专家(各自独立配置,使用统一 cutoff)
reports = await run_specialists(header, version=version, before=cutoff)
# model 作为形参下传,而非模块级变量:backtest 并发 8 场预测时,
# 模块级变量会被并发调用互相覆盖(模型串味)。
reports = await run_specialists(
header, version=version, before=cutoff, model_override=model
)
# 2.5 统计有效专家报告数量
ok_reports = [r for r in reports if r.status == "ok"]
@@ -263,7 +265,7 @@ async def predict_match_multi(
# 所有专家无数据/均失败:跳过终裁,标记 degraded
logger.warning(
"预测降级 match=%s mode=%s status=degraded experts=%d/%d 均无有效数据",
match_id, "multi", ok_reports, len(reports),
match_id, "multi", len(ok_reports), len(reports),
)
# 无有效专家时不调用 aggregator provider,避免多余开销
# model 使用 settings 默认值占位(无实际 LLM 调用)
@@ -279,14 +281,17 @@ async def predict_match_multi(
}
agg_prompt_tokens = 0
agg_completion_tokens = 0
aggregator_model = settings.LLM_MODEL # 占位,无实际 LLM 调用
aggregator_model = model or settings.LLM_MODEL # 占位,无实际 LLM 调用
latency_ms = int((time.perf_counter() - start) * 1000)
# 3.5 计算输入 hash(基于终裁报告)
input_hash = hashlib.sha256(
_reports_to_json(reports).encode("utf-8")
).hexdigest()
# P0-03: 指纹输入——终裁报告 hash 作 context_hash,专家列表作 agent_ids
reports_json = _reports_to_json(reports)
context_hash = hashlib.sha256(reports_json.encode("utf-8")).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)
async with get_uow() as session:
@@ -313,15 +318,20 @@ async def predict_match_multi(
pred_status = "degraded"
model_name = aggregator_model
pred = await _upsert_prediction(
pred = await _insert_or_find_by_fingerprint(
session,
match_id=match_id,
provider_name=settings.LLM_PROVIDER,
model=model_name,
mode="multi",
run_type="backtest" if backtest else "live",
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_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,
"completion_tokens": sum(r.completion_tokens or 0 for r in reports) + agg_completion_tokens,
"latency_ms": latency_ms,
@@ -339,7 +349,6 @@ async def predict_match_multi(
"match_kickoff_at": match_kickoff_at,
"prediction_cutoff_at": prediction_cutoff_at,
"prediction_created_at": now,
"input_hash": input_hash,
},
)
@@ -347,7 +356,7 @@ async def predict_match_multi(
"预测完成 match=%s mode=%s status=%s pred=%s:%s (%s) latency=%sms, experts=%d/%d, prediction_id=%s",
match_id, "multi", pred_status,
pred.pred_home_goals, pred.pred_away_goals, pred.pred_1x2,
latency_ms, ok_reports, len(reports), pred.id,
latency_ms, len(ok_reports), len(reports), pred.id,
)
return MultiPredictResult(
+37 -5
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass, field
from datetime import datetime
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.orm import selectinload
@@ -79,6 +79,35 @@ class BacktestSummary:
results: list[BacktestMatchResult] = field(default_factory=list)
def _parse_date_bound(value, *, end_of_day: bool) -> datetime | None:
"""把日期入参解析成可与 timestamptz 列比较的 aware datetime。
支持 "YYYY-MM-DD"完整 ISO (可带偏移)以及 datetime 对象;None 原样返回
裸日期按 UTC 锚定 Match.match_date timestamptz,naive datetime 与之
比较会因时区不同而偏移;start 取当天 00:00,end 取当天 23:59:59.999999
(闭区间,否则最后一天会被静默排除)
解析失败抛 ValueError(不静默吞掉):fromisoformat 对非法输入统一抛 ValueError,
这里包一层以带上原始值,便于定位是哪个参数写错了
"""
if value is None:
return None
if isinstance(value, datetime):
dt = value
else:
try:
dt = datetime.fromisoformat(str(value))
except ValueError as e:
raise ValueError(f"无法解析日期: {value!r}(应为 YYYY-MM-DD 或 ISO 格式)") from e
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
# 闭区间上界:日期串解析出来是 00:00,取当天末刻才能让最后一天参与回测
if end_of_day:
dt = dt.replace(hour=23, minute=59, second=59, microsecond=999999)
return dt
async def _get_historical_matches(
db,
*,
@@ -101,15 +130,18 @@ async def _get_historical_matches(
selectinload(Match.away_team),
)
.where(Match.match_status == "finished")
.where(Match.score_status == "known")
.where(Match.home_goals.is_not(None))
.where(Match.away_goals.is_not(None))
)
if league_id is not None:
stmt = stmt.where(Match.league_id == league_id)
if date_from:
stmt = stmt.where(Match.match_date >= date_from)
if date_to:
stmt = stmt.where(Match.match_date <= date_to)
dt_from = _parse_date_bound(date_from, end_of_day=False)
if dt_from is not None:
stmt = stmt.where(Match.match_date >= dt_from)
dt_to = _parse_date_bound(date_to, end_of_day=True)
if dt_to is not None:
stmt = stmt.where(Match.match_date <= dt_to)
stmt = stmt.order_by(Match.match_date.desc()).limit(limit)
result = await db.execute(stmt)
+62 -18
View File
@@ -5,13 +5,15 @@
"""
from __future__ import annotations
import hashlib
import logging
from datetime import datetime
from datetime import datetime, timedelta, timezone
from sqlalchemy import case, func, select
from src.db.base import AsyncSession, AsyncSessionLocal
from src.db.models import Match
from src.llm.predict import PredictResult, _insert_or_find_by_fingerprint
logger = logging.getLogger(__name__)
@@ -52,22 +54,28 @@ async def predict_baseline(
*,
backtest: bool = False,
cutoff_at: datetime | None = None,
) -> dict:
) -> PredictResult:
"""极简基线预测:主场场均进球 vs 客场场均进球。
返回 PredictResult 兼容的字典:
返回 PredictResult(D2 统一结果类型):
provider=model="baseline", 不调用 LLM,latency_ms0
P3-2:baseline 落库下沉到服务层 直接在服务层完成落库并回填真实
prediction_id,路由层不再需要特殊的 _persist_baseline, single/multi
路径统一(result.prediction_id 即可用)对外 JSON 不变
"""
from src.db.unit_of_work import get_uow
async with AsyncSessionLocal() as db:
match = await db.get(Match, match_id)
if match is None:
raise ValueError(f"match {match_id} not found")
before = None
if backtest and match.match_dt:
if backtest and match.match_date:
from datetime import timedelta
before = match.match_dt - timedelta(days=1)
before = match.match_date - timedelta(days=1)
elif cutoff_at is not None:
before = cutoff_at
@@ -90,24 +98,60 @@ async def predict_baseline(
else:
pred_1x2 = "X"
return {
"pred_home_goals": float(pred_home),
"pred_away_goals": float(pred_away),
"alt_pred_home_goals": None,
"alt_pred_away_goals": None,
"pred_1x2": pred_1x2,
"subjective_confidence": 0.5,
# 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 = {
"match_id": match_id,
"provider": "baseline",
"model": "baseline",
"mode": "baseline",
"run_type": "live",
"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,
"completion_tokens": 0,
"latency_ms": 0,
"pred_home_goals": float(pred_home),
"pred_away_goals": float(pred_away),
"pred_1x2": pred_1x2,
"subjective_confidence": 0.5,
"reasoning": (
f"基线估计(非投注建议): 主队主场场均进球 {home_avg:.2f} → 预测 {pred_home}; "
f"客队客场场均进球 {away_avg:.2f} → 预测 {pred_away}"
),
"provider": "baseline",
"model": "baseline",
"prompt_version": "baseline_v1",
"mode": "baseline",
"raw_response": {"home_avg": round(home_avg, 2), "away_avg": round(away_avg, 2)},
"status": "success",
"latency_ms": 0,
"raw": {"home_avg": round(home_avg, 2), "away_avg": round(away_avg, 2)},
}
# P0-03:服务层幂等插入,回填真实 prediction_id(与 single/multi 统一)。
async with get_uow() as session:
pred = await _insert_or_find_by_fingerprint(session, values=values)
prediction_id = pred.id
return PredictResult(
prediction_id=prediction_id,
provider="baseline",
model="baseline",
prompt_version="baseline_v1",
mode="baseline",
pred_home_goals=float(pred_home),
pred_away_goals=float(pred_away),
alt_pred_home_goals=None,
alt_pred_away_goals=None,
pred_1x2=pred_1x2,
subjective_confidence=0.5,
reasoning=values["reasoning"],
context="", # baseline 不构建 LLM 上下文
status="success",
latency_ms=0,
prompt_tokens=0,
completion_tokens=0,
raw=values["raw_response"],
)
+32 -547
View File
@@ -1,9 +1,9 @@
"""上下文构建器:数据切片 + 拼接。
"""上下文构建器:数据切片 + 拼接(聚合门面)
架构:
- match_header: 比赛基础信息(对阵双方/联赛/时间)
- 切片函数: 每个领域 agent 一个数据切片(h2h / form / standings / injuries / xg)
- build_context: agent 路径,拼接全部切片(行为与旧版一致)
实现按 slice 拆分(单文件 slices ),本模块只做再导出:
- 切片函数: 每个领域 agent 一个数据切片 src/llm/slices/{form,h2h,stats,home_away,standings}.py
- 共享类型/头信息/查询助手 src/llm/slices/common.py
- build_context: agent 路径,拼接全部切片(行为与旧版一致) src/llm/slices/aggregate.py
multi-agent 路径由 agents/orchestrator.py 调用切片函数,每个专家只拿自己的切片
@@ -11,548 +11,33 @@ multi-agent 路径由 agents/orchestrator.py 调用切片函数,每个专家只
build_context 创建一个共享 session 并传给所有切片函数,
避免每个切片独立创建 session 回测 20 场并发时,
5 个切片 × 20 = 100 个连接会耗尽连接池(pool_size=15)
消费方(路由/orchestrator/tests)仍从本模块 import,签名与拆分前完全一致
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from src.db.base import AsyncSessionLocal
from src.db.models import Match
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger(__name__)
def _outcome(home_goals: int, away_goals: int, side: str) -> str:
"""从某队视角看赛果: W/D/L。"""
if home_goals is None or away_goals is None:
return "?"
if side == "home":
return "W" if home_goals > away_goals else ("D" if home_goals == away_goals else "L")
return "W" if away_goals > home_goals else ("D" if away_goals == home_goals else "L")
def _is_stats_available(stats, before) -> bool:
"""检查统计数据在 cutoff 时间是否已可用。
available_at 语义:该条统计对外可被使用的最早时间,
至少不得早于比赛结束用于回测防泄漏
规则:
- before is None(实盘):available_at None 时允许(兼容旧数据)
- before is not None(回测):available_at None 视为不可用(保守)
- available_at > cutoff:不可用(数据在 cutoff 之后才生成)
"""
if before is None:
# 实盘模式:无时间信息时允许(兼容旧数据)
return True
# 回测模式(cutoff 不为 None):
# available_at 为 None → 无法确认是否在 cutoff 前可用,保守视为不可用
if stats.available_at is None:
return False
return stats.available_at <= before
@dataclass
class SliceResult:
"""数据切片的显式结果(替代「靠文案子串猜有无数据」)。
旧实现用 `"无数据" in slice_text` 判断,依赖具体文案 一旦某个切片
写成无比分数据无伤停数据这类变体,判断就会静默失配
(见审查报告 P2-1)这里让切片函数直接声明 `has_data`,不再猜
"""
text: str
has_data: bool
n_records: int = 0
def __str__(self) -> str: # 让老调用点可直接当 str 用
return self.text
@dataclass
class MatchContext:
match_id: int
text: str
has_stats: bool
has_injuries: bool
match_dt: object | None = None # 比赛时间(回测防泄漏 + 快照用)
cutoff: object | None = None # 实际使用的数据截止时间(用于落库记录)
@dataclass
class MatchHeader:
"""比赛基础信息(所有 agent 共享)。"""
match_id: int
home_name: str
away_name: str
league_name: str
season: str | None
match_date: str
match_dt: object # 原始 datetime,回测防泄漏用
stage: str | None
home_team_id: int
away_team_id: int
league_id: int
async def load_match_header(match_id: int, db: AsyncSession | None = None) -> MatchHeader:
"""加载比赛头信息(各 agent 共用)。
Args:
match_id: 比赛 ID
db: 可选的共享 session不传则自建(向后兼容)
"""
if db is not None:
m = await _load_match(db, match_id)
return _to_header(m)
async with AsyncSessionLocal() as new_db:
m = await _load_match(new_db, match_id)
return _to_header(m)
def _to_header(m: Match) -> MatchHeader:
return MatchHeader(
match_id=m.id,
home_name=m.home_team.name_zh or m.home_team.name,
away_name=m.away_team.name_zh or m.away_team.name,
league_name=m.league.name if m.league else "?",
season=m.season,
match_date=m.match_date.strftime("%Y-%m-%d %H:%M UTC") if m.match_date else "?",
match_dt=m.match_date,
stage=m.match_stage,
home_team_id=m.home_team_id,
away_team_id=m.away_team_id,
league_id=m.league_id,
)
def header_text(h: MatchHeader) -> str:
stage = f" {h.stage}" if h.stage else ""
return (
f"对阵: {h.home_name} vs {h.away_name} | {h.league_name} {h.season or '?'}{stage} | {h.match_date}"
)
# ============================================================
# 切片函数: 每个领域 agent 一个
# ============================================================
async def h2h_slice(header: MatchHeader, *, limit: int = 8, before=None, db: AsyncSession | None = None) -> SliceResult:
"""E - 历史交锋切片: 过去数年 + 近期交手数据,提取交手规律。before=match_date 用于回测。
db: 可选共享 session,避免每个切片独立建连(见模块 docstring)
"""
if db is not None:
h2h = await _get_h2h(db, header.home_team_id, header.away_team_id, before=before, limit=limit)
else:
async with AsyncSessionLocal() as new_db:
h2h = await _get_h2h(new_db, header.home_team_id, header.away_team_id, before=before, limit=limit)
lines = [f"── 历史交锋(近 {limit} 次) ──"]
n_with_score = 0
if h2h:
# 从当前主队视角统计:判断当前主队在每场交锋中是主是客
current_home_wins = current_home_draws = current_home_losses = 0
for hm in h2h:
d = hm.match_date.strftime("%Y-%m") if hm.match_date else "?"
if hm.home_goals is not None:
n_with_score += 1
# 判断当前主队当时是主队还是客队
if hm.home_team_id == header.home_team_id:
# 当前主队当时是主队
if hm.home_goals > hm.away_goals:
current_home_wins += 1
elif hm.home_goals == hm.away_goals:
current_home_draws += 1
else:
current_home_losses += 1
else:
# 当前主队当时是客队(从客队视角看赛果)
if hm.away_goals > hm.home_goals:
current_home_wins += 1
elif hm.away_goals == hm.home_goals:
current_home_draws += 1
else:
current_home_losses += 1
lines.append(f" {d}: {hm.home_team.name} {hm.home_goals}-{hm.away_goals} {hm.away_team.name}")
else:
lines.append(f" {d}: {hm.home_team.name} vs {hm.away_team.name} (无比分)")
total = current_home_wins + current_home_draws + current_home_losses
if total:
lines.append(
f" 总计 {total} 场(从当前主队 {header.home_name} 视角): "
f"{current_home_wins}{current_home_draws}{current_home_losses}"
)
else:
lines.append(" 无数据")
# has_data 以「有比分的交锋」为准:仅有对阵无比分时不足以支撑分析
return SliceResult(text="\n".join(lines), has_data=n_with_score > 0, n_records=n_with_score)
async def form_slice(header: MatchHeader, *, limit: int = 5, before=None, db: AsyncSession | None = None) -> SliceResult:
"""A - 近期状态切片: 两队近 N 场赛果、关键事件、走势判断。before=match_date 用于回测。
db: 可选共享 session,避免每个切片独立建连(见模块 docstring)
"""
if db is not None:
home_form = await _get_form(db, header.home_team_id, before=before, limit=limit)
away_form = await _get_form(db, header.away_team_id, before=before, limit=limit)
else:
async with AsyncSessionLocal() as new_db:
home_form = await _get_form(new_db, header.home_team_id, before=before, limit=limit)
away_form = await _get_form(new_db, header.away_team_id, before=before, limit=limit)
lines = []
n_scored = 0
# P0-1 修复:每场历史比赛必须根据「该队当时是主是客」判断 side,
# 不能用本场 side 硬套 —— 否则客场输球会被算成主场赢球。
for label, name, form, team_id in (
("主队", header.home_name, home_form, header.home_team_id),
("客队", header.away_name, away_form, header.away_team_id),
):
lines.append(f"── {label}近况({name},近 {limit} 场) ──")
if form:
wins = draws = losses = 0
for fm in form:
is_home = (fm.home_team_id == team_id)
side = "home" if is_home else "away"
o = _outcome(fm.home_goals, fm.away_goals, side)
if o == "W": wins += 1
elif o == "D": draws += 1
else: losses += 1
if fm.home_goals is not None:
n_scored += 1
score = f"{fm.home_goals}-{fm.away_goals}" if fm.home_goals is not None else "vs"
xg = ""
if fm.stats and _is_stats_available(fm.stats, before) and fm.stats.home_xg is not None:
own = fm.stats.home_xg if is_home else fm.stats.away_xg
xg = f" (xG {own:.1f})"
opp = fm.away_team.name if is_home else fm.home_team.name
lines.append(f" {o} {score} vs {opp}{xg}")
lines.append(f"{len(form)} 场: {wins}{draws}{losses}")
else:
lines.append(" 无数据")
return SliceResult(text="\n".join(lines), has_data=n_scored > 0, n_records=n_scored)
async def stats_slice(header: MatchHeader, *, limit: int = 10, before=None, db: AsyncSession | None = None) -> SliceResult:
"""B - 攻防数据切片: 进球、射门、控球,评估攻防强度。before=match_date 用于回测。
db: 可选共享 session,避免每个切片独立建连(见模块 docstring)
"""
if db is not None:
home_form = await _get_form(db, header.home_team_id, before=before, limit=limit)
away_form = await _get_form(db, header.away_team_id, before=before, limit=limit)
else:
async with AsyncSessionLocal() as new_db:
home_form = await _get_form(new_db, header.home_team_id, before=before, limit=limit)
away_form = await _get_form(new_db, header.away_team_id, before=before, limit=limit)
lines = [f"── 攻防数据(近 {limit} 场) ──"]
n_total = 0
# P0-1 修复:每场历史比赛必须根据「该队当时是主是客」判断 side,
# 不能用本场 side 硬套 —— 否则进球/失球/xG 全部算反。
for label, name, form, team_id in (
("主队", header.home_name, home_form, header.home_team_id),
("客队", header.away_name, away_form, header.away_team_id),
):
if form:
gf = ga = shots = sot = poss = xg = xga = 0
n = n_shots = n_poss = n_xg = 0
for fm in form:
if fm.home_goals is None: continue
is_home = (fm.home_team_id == team_id)
gf += fm.home_goals if is_home else fm.away_goals
ga += fm.away_goals if is_home else fm.home_goals
n += 1
# 只使用 cutoff 之前已可用的统计数据
if fm.stats and _is_stats_available(fm.stats, before):
if fm.stats.home_shots is not None:
shots += fm.stats.home_shots if is_home else fm.stats.away_shots
sot += fm.stats.home_shots_on_target if is_home else fm.stats.away_shots_on_target
n_shots += 1
if fm.stats.home_possession is not None:
poss += fm.stats.home_possession if is_home else (100 - fm.stats.home_possession)
n_poss += 1
if fm.stats.home_xg is not None:
xg += fm.stats.home_xg if is_home else fm.stats.away_xg
xga += fm.stats.away_xg if is_home else fm.stats.home_xg
n_xg += 1
n_total += n
if n > 0:
lines.append(f" {label} {name}:")
lines.append(f" 场均进球 {gf/n:.2f}, 场均失球 {ga/n:.2f}")
if n_shots: lines.append(f" 场均射门 {shots/n_shots:.1f}, 射正 {sot/n_shots:.1f}")
if n_poss: lines.append(f" 平均控球 {poss/n_poss:.1f}%")
if n_xg: lines.append(f" 场均 xG {xg/n_xg:.2f}, 场均被 xG {xga/n_xg:.2f}")
else:
lines.append(f" {label} {name}: 无比分数据")
else:
lines.append(f" {label} {name}: 无数据")
return SliceResult(text="\n".join(lines), has_data=n_total > 0, n_records=n_total)
async def home_away_slice(header: MatchHeader, *, limit: int = 10, before=None, db: AsyncSession | None = None) -> SliceResult:
"""C - 主客因素切片: 主场战绩 vs 客场战绩,评估地理优势影响。before=match_date 用于回测。
db: 可选共享 session,避免每个切片独立建连(见模块 docstring)
"""
if db is not None:
home_home = await _get_home_away(db, header.home_team_id, "home", before=before, limit=limit)
away_away = await _get_home_away(db, header.away_team_id, "away", before=before, limit=limit)
else:
async with AsyncSessionLocal() as new_db:
home_home = await _get_home_away(new_db, header.home_team_id, "home", before=before, limit=limit)
away_away = await _get_home_away(new_db, header.away_team_id, "away", before=before, limit=limit)
lines = ["── 主客因素 ──"]
n_total = 0
for label, name, matches, side in (
("主队主场", header.home_name, home_home, "home"),
("客队客场", header.away_name, away_away, "away"),
):
if matches:
wins = draws = losses = gf = ga = 0
for m in matches:
if m.home_goals is None: continue
o = _outcome(m.home_goals, m.away_goals, side)
if o == "W": wins += 1
elif o == "D": draws += 1
else: losses += 1
gf += m.home_goals if side == "home" else m.away_goals
ga += m.away_goals if side == "home" else m.home_goals
n = wins + draws + losses
n_total += n
if n > 0:
pct = wins / n * 100
lines.append(f" {label} {name}(近 {n} 场): {wins}{draws}{losses}负, 胜率 {pct:.0f}%")
lines.append(f" 场均进球 {gf/n:.2f}, 场均失球 {ga/n:.2f}")
else:
lines.append(f" {label} {name}: 无比分数据")
else:
lines.append(f" {label} {name}: 无数据")
return SliceResult(text="\n".join(lines), has_data=n_total > 0, n_records=n_total)
async def injuries_slice(header: MatchHeader, *, before=None, db: AsyncSession | None = None) -> SliceResult:
"""D - 阵容完整性切片: 伤停与停赛名单,评估战力缺失程度。
before=cutoff: 只使用 cutoff 之前已采集的伤停数据,防回测泄漏
db: 可选共享 session(见模块 docstring)
语义区分:
- 查询成功 + 空结果 has_data=True(明确知道无人伤停)
- 源未配置 / 查询失败 has_data=False(无法判断,跳过 LLM)
"""
from src.data.injuries import get_injuries_for_match, InjuryQueryResult
cutoff = before or header.match_dt
if db is not None:
home_result = await get_injuries_for_match(db, header.home_team_id, cutoff, as_of=cutoff)
away_result = await get_injuries_for_match(db, header.away_team_id, cutoff, as_of=cutoff)
else:
async with AsyncSessionLocal() as new_db:
home_result = await get_injuries_for_match(new_db, header.home_team_id, cutoff, as_of=cutoff)
away_result = await get_injuries_for_match(new_db, header.away_team_id, cutoff, as_of=cutoff)
# 判断是否有有效查询结果
# 两队都成功查询(即使为空) → has_data=True
# 任一查询失败或源未配置 → has_data=False
both_succeeded = (
home_result.query_status == "success"
and away_result.query_status == "success"
)
any_configured = (
home_result.query_status != "source_not_configured"
or away_result.query_status != "source_not_configured"
)
lines = ["── 阵容完整性 ──"]
n_records = 0
for label, result in (("主队", home_result), ("客队", away_result)):
if result.query_status == "source_not_configured":
lines.append(f" {label}: 伤停源未配置")
elif result.query_status == "query_error":
lines.append(f" {label}: 查询异常")
elif result.query_status == "no_local_data":
# API Key 已配置但本地无伤停记录
lines.append(f" {label}: 本地尚无伤停数据,请先采集")
elif result.records:
n_records += len(result.records)
lines.append(f" {label}伤停({len(result.records)}人):")
for inj in result.records[:8]:
reason = inj.reason or inj.injury_type or "未知"
lines.append(f" - {inj.player_name}: {reason}")
if len(result.records) > 8:
lines.append(f" ...及其他 {len(result.records) - 8}")
else:
# success + 空列表 → 明确无伤停
lines.append(f" {label}: 当前无伤停记录")
# 决定 has_data:
# - 两队都成功查询(即使为空) → True(明确知道名单)
# - 源未配置且无数据 → False
has_data = both_succeeded or (any_configured and n_records > 0)
if not has_data:
# 保留详细状态文案(伤停源未配置/查询异常),而非通用「无数据」
return SliceResult(text="\n".join(lines), has_data=False, n_records=0)
return SliceResult(text="\n".join(lines), has_data=True, n_records=n_records)
# ============================================================
# 单 agent 路径: 拼接全部切片(行为与旧版一致)
# ============================================================
async def build_context(match_id: int, *, form_last: int = 5, h2h_last: int = 5, backtest: bool = False, cutoff_at=None) -> MatchContext:
"""单 agent 路径的完整上下文: 拼接全部切片(before=cutoff,防未来信息)。
has_stats / has_injuries 直接取切片显式声明的 has_data,
不再靠文案子串匹配(见审查报告 P2-1)
P2-6: backtest=True cutoff = match_date - 1,确保只用赛前数据
cutoff_at: 显式截止时间(优先于 backtest 自动计算)
P1-1: 使用单个共享 session 贯穿所有切片查询,避免连接池耗尽
"""
async with AsyncSessionLocal() as db:
header = await load_match_header(match_id, db=db)
# 计算数据截止时间: 显式 > backtest 自动计算 > 默认(比赛时间)
if cutoff_at is not None:
cutoff = cutoff_at
elif backtest and header.match_dt:
from datetime import timedelta
cutoff = header.match_dt - timedelta(days=1)
else:
cutoff = header.match_dt
parts = [header_text(header), ""]
form_res = await form_slice(header, limit=form_last, before=cutoff, db=db)
parts.append(form_res.text)
parts.append("")
h2h_res = await h2h_slice(header, limit=h2h_last, before=cutoff, db=db)
parts.append(h2h_res.text)
parts.append("")
stats_res = await stats_slice(header, before=cutoff, db=db)
parts.append(stats_res.text)
parts.append("")
home_away_res = await home_away_slice(header, before=cutoff, db=db)
parts.append(home_away_res.text)
parts.append("")
injuries_res = await injuries_slice(header, before=cutoff, db=db)
parts.append(injuries_res.text)
return MatchContext(
match_id=match_id,
text="\n".join(parts),
has_stats=form_res.has_data or stats_res.has_data,
has_injuries=injuries_res.has_data,
match_dt=header.match_dt,
cutoff=cutoff,
)
# ============================================================
# 底层查询(切片函数共用)
# ============================================================
async def _load_match(db, match_id: int) -> Match:
stmt = (
select(Match)
.where(Match.id == match_id)
.options(
selectinload(Match.league),
selectinload(Match.home_team),
selectinload(Match.away_team),
selectinload(Match.stats),
)
)
m = (await db.execute(stmt)).scalar_one_or_none()
if m is None:
raise ValueError(f"match {match_id} not found")
return m
async def _get_form(db, team_id: int, before, *, limit: int = 5) -> list[Match]:
"""某队近 N 场(已完赛)。before=None 表示不限制(预测赛前的场景由调用方保证)。
必须预加载 stats / home_team / away_team:切片函数会读取这些关系,
async session 下惰性加载会抛 MissingGreenlet
(models.py 已声明 lazy="selectin",此处显式声明以固化查询意图)
"""
stmt = (
select(Match)
.options(
selectinload(Match.stats),
selectinload(Match.home_team),
selectinload(Match.away_team),
)
.where(Match.match_status == "finished")
.where(Match.home_goals.is_not(None))
.where((Match.home_team_id == team_id) | (Match.away_team_id == team_id))
.order_by(Match.match_date.desc())
.limit(limit)
)
if before is not None:
stmt = stmt.where(Match.match_date < before)
result = await db.execute(stmt)
return list(result.scalars().all())
async def _get_h2h(db, home_id: int, away_id: int, before, *, limit: int = 5) -> list[Match]:
"""两队交锋史。需预加载 home_team / away_team(切片输出队名)。"""
stmt = (
select(Match)
.options(
selectinload(Match.home_team),
selectinload(Match.away_team),
)
.where(Match.match_status == "finished")
.where(Match.home_goals.is_not(None))
.where(
((Match.home_team_id == home_id) & (Match.away_team_id == away_id))
| ((Match.home_team_id == away_id) & (Match.away_team_id == home_id))
)
.order_by(Match.match_date.desc())
.limit(limit)
)
if before is not None:
stmt = stmt.where(Match.match_date < before)
result = await db.execute(stmt)
return list(result.scalars().all())
async def _get_home_away(db, team_id: int, side: str, before, *, limit: int = 10) -> list[Match]:
"""某队主场/客场近 N 场。side='home' 取主场,'away' 取客场。
当前只用标量字段,但统一预加载以免后续扩展时踩坑
"""
stmt = (
select(Match)
.options(
selectinload(Match.stats),
selectinload(Match.home_team),
selectinload(Match.away_team),
)
.where(Match.match_status == "finished")
.where(Match.home_goals.is_not(None))
.order_by(Match.match_date.desc())
.limit(limit)
)
if side == "home":
stmt = stmt.where(Match.home_team_id == team_id)
else:
stmt = stmt.where(Match.away_team_id == team_id)
if before is not None:
stmt = stmt.where(Match.match_date < before)
result = await db.execute(stmt)
return list(result.scalars().all())
# ── 共享类型与基础(判空/赛果/统计可用性) ──
from src.llm.slices.common import ( # noqa: F401
MatchContext,
MatchHeader,
SliceResult,
_is_stats_available,
_outcome,
header_text,
load_match_header,
)
# ── 领域切片 ──
from src.llm.slices.form import form_slice # noqa: F401
from src.llm.slices.h2h import h2h_slice # noqa: F401
from src.llm.slices.home_away import home_away_slice # noqa: F401
from src.llm.slices.standings import standings_slice # noqa: F401
from src.llm.slices.stats import stats_slice # noqa: F401
# ── 底层查询助手(切片函数共用;orchestrator/tests 直接引用) ──
from src.llm.slices.form import _get_form # noqa: F401
from src.llm.slices.h2h import _get_h2h # noqa: F401
from src.llm.slices.home_away import _get_home_away # noqa: F401
# ── 单 agent 聚合入口 ──
from src.llm.slices.aggregate import build_context # noqa: F401
+3 -6
View File
@@ -7,6 +7,7 @@ from sqlalchemy import func, or_, select
from src.db.models import Prediction, Match, League
from src.db.unit_of_work import get_uow
from src.llm.utils import actual_1x2
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:
"""根据实际比分返胜平负。"""
if home > away:
return "1"
if home == away:
return "X"
return "2"
"""根据实际比分返胜平负(委托 utils.actual_1x2 单一权威源)"""
return actual_1x2(home, away)
def _build_filters(
+216 -76
View File
@@ -1,4 +1,16 @@
"""预测服务:拼上下文 → 调 LLM → 存预测。"""
"""预测服务:拼上下文 → 调 LLM → 存预测。
落库层级(table: predictions):
| 模式 | 落库位置(服务层) | 路由层(routes/predict.py) |
|-----------|-------------------------------------------------------------|---------------------------|
| single | `_predict_single` `_insert_or_find_by_fingerprint` | 不读 DB,仅映射 result PredictOut |
| multi | `orchestrator.predict_match_multi` `_insert_or_find_by_fingerprint` | 不读 DB,仅映射 result PredictOut |
| baseline | `predict_baseline` `_insert_or_find_by_fingerprint` | 不读 DB,仅映射 result PredictOut |
三种模式统一在服务层经 UnitOfWork 落库并回填真实 prediction_id;
路由层永不写入 predictions,只读 result.prediction_id 做响应映射
"""
from __future__ import annotations
import functools
@@ -25,9 +37,7 @@ _PROMPT_DIR = Path(__file__).resolve().parent / "prompts"
# ── LLM 响应缓存(match+provider+model+version → 结果) ──
_CACHE_TTL_SEC = 300 # 5 分钟
_CACHE_MAX_SIZE = 200 # P3-1: 有上限,避免长期运行内存无限增长
# P1-5: 缓存仅在 asyncio 协程内同步访问(dict 操作 GIL 原子),无需 threading.Lock。
# 删除 _cache_lock,避免同步锁阻塞事件循环;dict 的 get/set 在 CPython 下原子。
_cache: dict[str, tuple[float, PredictResult]] = {}
_CACHE_PREFIX = "predict:" # Redis key 前缀
def _cache_key(match_id: int, provider: str, model: str, version: str, tpl_hash: str) -> str:
@@ -36,43 +46,132 @@ def _cache_key(match_id: int, provider: str, model: str, version: str, tpl_hash:
仅用 version 做键不够 编辑器里改动 `match_prediction_v1.md` 而版本号
不变时,进程内缓存仍会返回旧模板产生的旧结果(见审查报告 P2-6)
把模板内容 hash 纳入键,模板一改缓存自动失效
内存与 Redis 共用同一键格式,TTL 一致
"""
return f"{match_id}:{provider}:{model}:{version}:{tpl_hash[:12]}"
return f"{_CACHE_PREFIX}{match_id}:{provider}:{model}:{version}:{tpl_hash[:12]}"
def _get_cached(match_id: int, provider: str, model: str, version: str, tpl_hash: str) -> PredictResult | None:
# P1-5: 无锁访问。dict get/del 在 CPython GIL 下原子,且无 await 穿插。
key = _cache_key(match_id, provider, model, version, tpl_hash)
entry = _cache.get(key)
if entry is not None:
ts, result = entry
if time.time() - ts < _CACHE_TTL_SEC:
return result
_cache.pop(key, None)
return None
# ── 缓存后端:内存(LRU+TTL),可选 Redis ──────────────────────────
class _CacheBackend:
"""缓存后端统一接口:_get 同步返回(命中时),_set 异步(Redis 为 async,内存同步)。"""
def _raw_key(self, key: str) -> str:
return key
def get(self, key: str) -> PredictResult | None:
raise NotImplementedError
async def set(self, key: str, result: PredictResult, ttl: int) -> None:
raise NotImplementedError
def _set_cached(match_id: int, provider: str, model: str, version: str, tpl_hash: str, result: PredictResult) -> None:
# P1-5: 无锁写入。同上,dict set 原子。
key = _cache_key(match_id, provider, model, version, tpl_hash)
_cache[key] = (time.time(), result)
# P3-1: 超过上限时淘汰最旧条目(按时间戳排序)
if len(_cache) > _CACHE_MAX_SIZE:
oldest_key = min(_cache, key=lambda k: _cache[k][0])
_cache.pop(oldest_key, None)
class _MemoryCache(_CacheBackend):
"""进程内 LRU+TTL 缓存(默认后端)。"""
def __init__(self) -> None:
self._store: dict[str, tuple[float, PredictResult]] = {}
def get(self, key: str) -> PredictResult | None:
entry = self._store.get(key)
if entry is not None:
ts, result = entry
if time.time() - ts < _CACHE_TTL_SEC:
return result
self._store.pop(key, None)
return None
async def set(self, key: str, result: PredictResult, ttl: int) -> None:
self._store[key] = (time.time(), result)
if len(self._store) > _CACHE_MAX_SIZE:
oldest_key = min(self._store, key=lambda k: self._store[k][0])
self._store.pop(oldest_key, None)
def clear_prompt_cache() -> None:
"""清空 prompt 模板缓存(供开发/热更新时手动调用)
class _RedisCache(_CacheBackend):
"""可选 Redis 后端: PREDICT_CACHE_URL 非空时启用
lru_cache 的模板缓存是进程级的,改完 .md 需要重启进程才能生效;
提供显式清理入口,避免"改了模板却看不到变化"的困惑(见审查报告 P2-5)
失败降级:读失败返回 None(跳过缓存),写失败打 warning;不中断预测主流程
不强制依赖 redis 未安装时启动回退内存并 warning
"""
_load_prompt_template.cache_clear()
logger.info("prompt 模板缓存已清空")
def __init__(self, url: str) -> None:
self._url = url
self._redis = None # type: ignore[var-annotated]
self._memory_fallback = _MemoryCache()
self._available: bool | None = None # None=未探测,True=可用,False=不可用
async def _ensure_conn(self) -> bool:
"""懒初始化 Redis 连接;失败返回 False 并降级内存。"""
if self._available is not None:
return self._available
try:
from redis.asyncio import Redis
self._redis = Redis.from_url(self._url, decode_responses=True, socket_timeout=2.0)
await self._redis.ping()
self._available = True
logger.info("predict cache: Redis 后端已连接 %s", self._url.replace(self._url.split("@")[-1] if "@" in self._url else self._url, "***") if "://" in self._url else "redis")
except Exception as e:
self._available = False
logger.warning("predict cache: Redis 连接失败(%s),降级内存缓存", e)
return self._available
def get(self, key: str) -> PredictResult | None:
# Redis get 是 async 的,此处统一由调用方走 async 路径;
# 同步 get 仅用于不可降级场景——Redis 模式下直接返回 None,
# 实际读取通过 get_async 完成。
return None
async def get_async(self, key: str) -> PredictResult | None:
if not await self._ensure_conn():
return self._memory_fallback.get(key)
try:
import pickle
raw = await self._redis.get(key) # type: ignore[union-attr]
if raw is None:
return None
return pickle.loads(raw.encode("latin-1")) if isinstance(raw, str) else pickle.loads(raw)
except Exception as e:
logger.warning("predict cache: Redis GET 失败(%s),跳过缓存", e)
return None
async def set(self, key: str, result: PredictResult, ttl: int) -> None:
if not await self._ensure_conn():
await self._memory_fallback.set(key, result, ttl)
return
try:
import pickle
payload = pickle.dumps(result).decode("latin-1")
await self._redis.set(key, payload, ex=ttl) # type: ignore[union-attr]
except Exception as e:
logger.warning("predict cache: Redis SET 失败(%s),降级内存写入", e)
await self._memory_fallback.set(key, result, ttl)
def _build_cache_backend() -> _CacheBackend:
url = getattr(settings, "PREDICT_CACHE_URL", None)
if url:
return _RedisCache(url)
return _MemoryCache()
_cache_backend: _CacheBackend = _build_cache_backend()
async def _get_cached(match_id: int, provider: str, model: str, version: str, tpl_hash: str) -> PredictResult | None:
key = _cache_key(match_id, provider, model, version, tpl_hash)
if isinstance(_cache_backend, _RedisCache):
return await _cache_backend.get_async(key)
return _cache_backend.get(key)
async def _set_cached(match_id: int, provider: str, model: str, version: str, tpl_hash: str, result: PredictResult) -> None:
key = _cache_key(match_id, provider, model, version, tpl_hash)
await _cache_backend.set(key, result, _CACHE_TTL_SEC)
@functools.lru_cache(maxsize=8)
def _load_prompt_template(version: str = "v1") -> str:
"""缓存 prompt 模板(进程生命周期内每个版本只读一次)。"""
path = _PROMPT_DIR / f"match_prediction_{version}.md"
@@ -89,6 +188,14 @@ def _prompt_template_hash(version: str) -> str:
@dataclass
class PredictResult:
"""三种预测模式(single/multi/baseline)的统一结果类型。
D2(工程债): 原本 single 返回本类multi 重复定义 MultiPredictResult
baseline 返回裸 dict,导致路由 isinstance(dict) 双分支 + backtest
baseline 直接 AttributeError现以可选字段扩展本类承载全部模式;
MultiPredictResult 是本类的别名( src/llm/agents/orchestrator.py)
"""
prediction_id: int
provider: str
model: str
@@ -101,49 +208,72 @@ class PredictResult:
subjective_confidence: float | None
reasoning: str | None
context: str
# 模式标识: single(默认) / multi / baseline
mode: str = "single"
# multi 专属: 各专家报告列表与融合权重;single/baseline 为 None
agent_outputs: list[dict] | None = None
agent_weights: dict | None = None
status: str = "success"
latency_ms: int | None = None
prompt_tokens: int | None = None
completion_tokens: int | None = None
raw: dict | None = None
async def _upsert_prediction(
session,
*,
match_id: int,
provider_name: str,
model: str,
mode: str,
run_type: str,
values: dict,
) -> Prediction:
"""按 (match, provider, model, mode, run_type) 唯一约束写入预测。
def _compute_fingerprint(values: dict) -> str:
"""P0-03: 预测指纹(规范 JSON 的 SHA-256)。
已存在且未结算 覆盖更新(重新预测语义);已结算 拒绝(保护评估数据)
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 = (
await session.execute(
select(Prediction).where(
Prediction.match_id == match_id,
Prediction.provider == provider_name,
Prediction.model == model,
Prediction.mode == mode,
Prediction.run_type == run_type,
)
select(Prediction).where(Prediction.input_hash == fingerprint)
)
).scalar_one_or_none()
if existing is not None and existing.settled:
raise ValueError("该比赛已有已结算的预测,不能重新预测")
if existing is not None:
# 同指纹 → 直接返回,绝不覆盖 pred_* / reasoning / agent_outputs
return existing
pred = existing if existing is not None else Prediction(
match_id=match_id, provider=provider_name, model=model,
)
pred.mode = mode
pred.run_type = run_type
for k, v in values.items():
setattr(pred, k, v)
if existing is None:
session.add(pred)
pred = Prediction(**{k: v for k, v in values.items() if hasattr(Prediction, k)})
session.add(pred)
await session.flush() # 拿到自增 id;事务由 UnitOfWork 退出时提交
return pred
@@ -158,9 +288,11 @@ async def predict_match(
use_cache: bool = True,
backtest: bool = False,
cutoff_at=None,
) -> "PredictResult | MultiPredictResult":
) -> PredictResult:
"""预测入口。mode=multi(默认)走多 agent;mode=single 走单次调用;mode=baseline 走无 LLM 基线。
三种模式统一返回 PredictResult(D2);multi MultiPredictResult 是其别名
Args:
mode: multi(默认,5 专家+终裁) / single(单次) / baseline(极简统计基线,不调用 LLM)
use_cache:是否允许返回进程内缓存结果回测必须传 False
@@ -187,13 +319,14 @@ async def predict_match(
)
from src.llm.agents.orchestrator import predict_match_multi
# 回测参数完整传递到 multi-agent 路径
# 回测参数 + 模型覆盖完整传递到 multi-agent 路径
return await predict_match_multi(
match_id,
provider=provider,
version=(prompt_version or "v1").removeprefix("multi_"),
backtest=backtest,
cutoff_at=cutoff_at,
model=model,
)
@@ -217,7 +350,7 @@ async def _predict_single(
# 0. 查缓存(同 match+provider+model+version+模板hash 5 分钟内直接返)
if use_cache:
cached = _get_cached(match_id, settings.LLM_PROVIDER, provider.model, version, tpl_hash)
cached = await _get_cached(match_id, settings.LLM_PROVIDER, provider.model, version, tpl_hash)
if cached is not None:
logger.debug("predict cache hit match=%s", match_id)
return cached
@@ -225,23 +358,26 @@ async def _predict_single(
# 1. 拼上下文(backtest/cutoff 防泄漏)
ctx = await build_context(match_id, backtest=backtest, cutoff_at=cutoff_at)
# 1.5 计算快照元数据(用于可复现性)
# 1.5 计算快照元数据(用于可复现性 + P0-03 指纹)
now = datetime.now(timezone.utc)
match_kickoff_at = ctx.match_dt
# 使用上下文实际计算的 cutoff(回测时可能为 match_dt-1天),而非开球时间
prediction_cutoff_at = ctx.cutoff if ctx.cutoff is not None else ctx.match_dt
input_hash = hashlib.sha256(ctx.text.encode("utf-8")).hexdigest()
# 2. 拼 prompt(指定版本)
template = _load_prompt_template(version)
prompt_hash = _prompt_template_hash(version)
user_prompt = template.replace("{{context}}", ctx.text)
system_prompt = "你是一个严谨的足球预测专家。只输出 JSON。"
context_hash = hashlib.sha256(ctx.text.encode("utf-8")).hexdigest()
# 3. 调 LLM
temperature = 0.3
resp = await provider.chat(
system="你是一个严谨的足球预测专家。只输出 JSON。",
system=system_prompt,
user=user_prompt,
json_mode=True,
temperature=0.3,
temperature=temperature,
max_tokens=4096, # 推理模型的 reasoning 也计入输出 token,需留足余量
)
@@ -268,15 +404,20 @@ async def _predict_single(
if m is None:
raise ValueError(f"match {match_id} not found")
pred = await _upsert_prediction(
pred = await _insert_or_find_by_fingerprint(
session,
match_id=match_id,
provider_name=settings.LLM_PROVIDER,
model=provider.model,
mode="single",
run_type="backtest" if backtest else "live",
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_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,
"completion_tokens": resp.completion_tokens,
"latency_ms": resp.latency_ms,
@@ -292,7 +433,6 @@ async def _predict_single(
"match_kickoff_at": match_kickoff_at,
"prediction_cutoff_at": prediction_cutoff_at,
"prediction_created_at": now,
"input_hash": input_hash,
},
)
@@ -316,7 +456,7 @@ async def _predict_single(
# 5. 写入缓存(仅当允许缓存时)
if use_cache:
_set_cached(match_id, settings.LLM_PROVIDER, provider.model, version, tpl_hash, result)
await _set_cached(match_id, settings.LLM_PROVIDER, provider.model, version, tpl_hash, result)
logger.info(
"预测完成 match=%s mode=%s status=%s pred=%s:%s (%s) latency=%sms",
match_id, "single", "success",
-21
View File
@@ -1,21 +0,0 @@
你是足球阵容完整性分析专家。分析以下两队的伤停与停赛信息,评估战力缺失程度。
{{context}}
分析要点:
- 核心球员缺阵影响(射手/组织核心/主力门将/后防中坚)
- 缺阵人数与位置分布(前场/中场/后场)
- 替补深度:缺阵是否有人可替
- 无数据时如实标注 data_sufficiency=none,不猜测
- 综合判断:哪支球队战力受损更严重
严格按此 JSON 输出,不要其他内容:
```json
{
"data_sufficiency": "high|medium|low|none",
"analysis": "<150 字内分析,量化战力缺失程度>",
"home_edge": <-1.0 到 1.0, 正数=客队伤停更严重(利主队)>,
"confidence": <0.0-1.0>,
"key_evidence": ["<证据1>", "<证据2>"]
}
```

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