diff --git a/docs/06-deployment.md b/docs/06-deployment.md index a87c365..0607bc6 100644 --- a/docs/06-deployment.md +++ b/docs/06-deployment.md @@ -46,6 +46,9 @@ curl http://localhost:8000/health - [ ] **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 …`,升级镜像重启即自动迁移,无需手动执行 @@ -99,10 +102,29 @@ cd frontend && npm install && npm run dev | `LLM_AGGREGATOR_MODEL` | ❌ | — | 终裁模型(回落 `LLM_MODEL`) | | `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 diff --git a/frontend/src/pages/matches/components/AgentsPanel.tsx b/frontend/src/pages/matches/components/AgentsPanel.tsx new file mode 100644 index 0000000..2b70361 --- /dev/null +++ b/frontend/src/pages/matches/components/AgentsPanel.tsx @@ -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 = { + 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 = { + 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 ( +
+ + = 0 ? 'bg-press' : 'bg-ink-600'}`} + style={ + v >= 0 + ? { left: '50%', width: `${half}%` } + : { right: '50%', width: `${half}%` } + } + /> +
+ ) +} + +/** 单路专家意见:汉字编号 + 细线行 */ +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 ( +
+ + {no} + {AGENT_LABELS[r.agent] ?? r.agent} + {badge.label} + + + {r.status === 'ok' && r.subjective_confidence !== null && ( + 信心 {Math.round(r.subjective_confidence * 100)}% + )} + {r.status === 'ok' && r.probable_score && ( + {r.probable_score} + )} + + + + +
+ {inactive && ( +

+ {r.status === 'no_data' && '该维度没有可用数据,已跳过 LLM 分析以节省额度(不影响其他专家)。'} + {r.status === 'error' && '该专家调用失败,本次结论未纳入其视角(fail-open 设计,不阻断整体预测)。'} + {r.status === 'parse_error' && '模型输出未通过格式校验,该报告已丢弃。'} +

+ )} + + {!inactive && r.home_edge !== null && ( +
+
+ 主队优势 + 0 ? 'text-press' : r.home_edge < 0 ? 'text-ink-700' : 'text-ink-500'}`}> + {r.home_edge > 0 ? '+' : ''}{r.home_edge.toFixed(2)} + +
+ +
+ 利客队 + 利主队 +
+
+ )} + + {r.analysis && ( +

{r.analysis}

+ )} + + {r.key_evidence.length > 0 && ( +
    + {r.key_evidence.map((e, i) => ( +
  • + + {e} +
  • + ))} +
+ )} + + {r.exp_home_goals !== null && r.exp_away_goals !== null && ( +

+ 进球期望 {r.exp_home_goals.toFixed(1)} - {r.exp_away_goals.toFixed(1)} +

+ )} + + {!inactive && ( +

+ 数据充分度 {SUFFICIENCY_LABEL[r.data_sufficiency] ?? r.data_sufficiency} + | + {r.model} + {r.latency_ms !== null && {r.latency_ms}ms} +

+ )} +
+
+ ) +} + +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 ( +
+ + + {!degraded && prediction.agent_weights && Object.keys(prediction.agent_weights).length > 0 && ( +
+ 终裁权重分布 + {Object.entries(prediction.agent_weights) + .sort((a, b) => b[1] - a[1]) + .map(([k, v]) => ( +
+ {AGENT_LABELS[k] ?? k} +
+
+
+ {Math.round(v * 100)}% +
+ ))} +
+ )} + + {expertsOpen && ( +
+ {reports.map((r, i) => ( + + ))} +
+ )} +
+ ) +} diff --git a/frontend/src/pages/matches/components/MatchPredictPanel.tsx b/frontend/src/pages/matches/components/MatchPredictPanel.tsx index a551cd9..88cbce1 100644 --- a/frontend/src/pages/matches/components/MatchPredictPanel.tsx +++ b/frontend/src/pages/matches/components/MatchPredictPanel.tsx @@ -6,37 +6,11 @@ */ import { useEffect, useState } from 'react' import TeamSideTag from '../../../components/TeamSideTag' -import type { AgentReport, Match, Prediction } from '../types' -import { AGENT_LABELS, CN_NUM, OUTCOME_LABEL } from '../types' - -/** 置信度细线:0~1 数值的低调可视化 */ -function Meter({ value }: { value: number }) { - const pct = Math.max(0, Math.min(100, Math.round(value * 100))) - return ( -
-
-
- ) -} - -/** 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 ( -
- - = 0 ? 'bg-press' : 'bg-ink-600'}`} - style={ - v >= 0 - ? { left: '50%', width: `${half}%` } - : { right: '50%', width: `${half}%` } - } - /> -
- ) -} +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 ( @@ -53,77 +27,73 @@ function Spinner({ className = '' }: { className?: string }) { } /** 胜平负一行文字:选中的红字加方块标记,未选中的退灰 */ -function OutcomeLine({ - pick, - confidence, +.** + * P3-1:PredictionPanel 不再自绘,改为组合三个子组件: + * OutcomePanel(比分/胜平负/成本) / AgentsPanel(专家意见) / ReasoningPanel(终裁/降级)。 + * 渲染输出与拆分前完全一致(仅降级警示 + 报头 + 元信息仍在此处)。 + */ +function PredictionPanel({ + prediction, + match, + embedded = false, }: { - pick: string | null - confidence: number | null + prediction: Prediction + match: Match + /** 弹窗嵌入模式:弹窗已提供报头,这里省略自带版头 */ + embedded?: boolean }) { - const options = ['1', 'X', '2'] as const + 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 ( -
-
- {options.map(o => { - const on = pick === o - return ( -
- - {on && - {on && confidence !== null && ( - - 置信 {Math.round(confidence * 100)}% - - )} -
- ) - })} +
+ {!embedded && ( +
+

+ 预测版 · + + {homeName} + + + {awayName} +

+ + {prediction.provider} / {prediction.model} + {prediction.latency_ms !== null && ` · ${(prediction.latency_ms / 1000).toFixed(1)}s`} +
- {pick && confidence !== null && ( -
- -

主观置信度,非统计概率

-
)} -
- ) -} -/** 预测成本展示:耗时 + 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 +
+ {degraded && ( +
+

+ {prediction.status === 'failed' ? '预测失败' : '预测降级(degraded)'} +

+

+ {prediction.reasoning || '所有专家均无有效数据或调用失败,无法生成可靠比分。'} +

+
+ )} - if (!latency && !tokens && prediction.rate_limit_remaining == null) return null + {!degraded && } - return ( -
-
- {latency && ( - - 耗时 {latency} - - )} - {tokens && ( - - prompt/completion: {tokens} - - )} - {prediction.rate_limit_remaining != null && prediction.rate_limit_remaining <= 3 && ( - - 剩余配额: {prediction.rate_limit_remaining}/10(分钟) - - )} +

+ `多专家模式 · ${okReports.length}/${reports.length} 路有效` + {prediction.prompt_version && ` · prompt ${prediction.prompt_version}`} +

+ + + +
-
+ ) } -/** 预测过程阶段(按时长模拟;结果到达即跳到完成) */ function PredictProgress() { const [elapsed, setElapsed] = useState(0) useEffect(() => { @@ -199,256 +169,6 @@ function PredictProgress() {
) } - -const STATUS_BADGE: Record = { - 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 = { - high: '充分', - medium: '一般', - low: '偏少', - none: '无', -} - -/** 单路专家意见:汉字编号 + 细线行 */ -function AgentCard({ report: r, no }: { report: AgentReport; no: string }) { - const badge = STATUS_BADGE[r.status] ?? { label: r.status, cls: 'text-ink-400' } - const inactive = r.status !== 'ok' - - return ( -
- - {no} - {AGENT_LABELS[r.agent] ?? r.agent} - {badge.label} - - - {r.status === 'ok' && r.subjective_confidence !== null && ( - 信心 {Math.round(r.subjective_confidence * 100)}% - )} - {r.status === 'ok' && r.probable_score && ( - {r.probable_score} - )} - - - - -
- {/* 无数据 / 失败时给出明确说明,避免用户以为是空白 bug */} - {inactive && ( -

- {r.status === 'no_data' && '该维度没有可用数据,已跳过 LLM 分析以节省额度(不影响其他专家)。'} - {r.status === 'error' && '该专家调用失败,本次结论未纳入其视角(fail-open 设计,不阻断整体预测)。'} - {r.status === 'parse_error' && '模型输出未通过格式校验,该报告已丢弃。'} -

- )} - - {!inactive && r.home_edge !== null && ( -
-
- 主队优势 - 0 ? 'text-press' : r.home_edge < 0 ? 'text-ink-700' : 'text-ink-500'}`}> - {r.home_edge > 0 ? '+' : ''}{r.home_edge.toFixed(2)} - -
- -
- 利客队 - 利主队 -
-
- )} - - {r.analysis && ( -

{r.analysis}

- )} - - {r.key_evidence.length > 0 && ( -
    - {r.key_evidence.map((e, i) => ( -
  • - - {e} -
  • - ))} -
- )} - - {r.exp_home_goals !== null && r.exp_away_goals !== null && ( -

- 进球期望 {r.exp_home_goals.toFixed(1)} - {r.exp_away_goals.toFixed(1)} -

- )} - - {!inactive && ( -

- 数据充分度 {SUFFICIENCY_LABEL[r.data_sufficiency] ?? r.data_sufficiency} - | - {r.model} - {r.latency_ms !== null && {r.latency_ms}ms} -

- )} -
-
- ) -} - -function PredictionPanel({ - prediction, - match, - embedded = false, -}: { - prediction: Prediction - match: Match - /** 弹窗嵌入模式:弹窗已提供报头,这里省略自带版头 */ - embedded?: boolean -}) { - const homeName = match.home_team_zh || match.home_team - const [expertsOpen, setExpertsOpen] = useState(false) - const awayName = match.away_team_zh || match.away_team - const degraded = prediction.status === 'degraded' || prediction.status === 'failed' - const reports = prediction.agent_outputs ?? [] - const okReports = reports.filter(r => r.status === 'ok') - - return ( -
- {!embedded && ( -
-

- 预测版 · - - {homeName} - - - {awayName} -

- - {prediction.provider} / {prediction.model} - {prediction.latency_ms !== null && ` · ${(prediction.latency_ms / 1000).toFixed(1)}s`} - -
- )} - -
- {/* ── degraded / failed 态:醒目警示 + 原因,不展示虚假比分 ── */} - {degraded && ( -
-

- {prediction.status === 'failed' ? '预测失败' : '预测降级(degraded)'} -

-

- {prediction.reasoning || '所有专家均无有效数据或调用失败,无法生成可靠比分。'} -

-
- )} - - {/* ── 主结论(仅 success 展示) ── */} - {!degraded && ( - <> -
-

- {prediction.pred_home_goals ?? '-'} - : - {prediction.pred_away_goals ?? '-'} -

-

预测比分

- {prediction.alt_pred_home_goals != null && prediction.alt_pred_away_goals != null && ( -

- 备选{' '} - - {prediction.alt_pred_home_goals}:{prediction.alt_pred_away_goals} - -

- )} -
- -
- -
- - )} - - {/* ── 成本信息(耗时 + token + 限流余量) ── */} - {!degraded && ( - - )} - - {/* ── 元信息 ── */} -

- `多专家模式 · ${okReports.length}/${reports.length} 路有效` - {prediction.prompt_version && ` · prompt ${prediction.prompt_version}`} -

- - {/* ── 终裁/降级说明意见 ── */} - {prediction.reasoning && degraded && ( -
-

降级原因

-
-

{prediction.reasoning}

-
-
- )} - - {/* ── 专家意见(多模式):可折叠 + 状态摘要 + 权重条形图 ── */} - {reports.length > 0 && ( -
- - - {/* 权重条形图(仅 success 且有权重时显示) */} - {!degraded && prediction.agent_weights && Object.keys(prediction.agent_weights).length > 0 && ( -
- 终裁权重分布 - {Object.entries(prediction.agent_weights) - .sort((a, b) => b[1] - a[1]) - .map(([k, v]) => ( -
- {AGENT_LABELS[k] ?? k} -
-
-
- {Math.round(v * 100)}% -
- ))} -
- )} - - {expertsOpen && ( -
- {reports.map((r, i) => ( - - ))} -
- )} -
- )} - - {/* ── 终裁意见(success) ── */} - {prediction.reasoning && !degraded && ( -
-

终裁意见

-
-

{prediction.reasoning}

-
-
- )} -
-
- ) -} - -/** 预测弹窗:进行中显示过程可视化,完成后显示预测版,失败显示原因 */ export function PredictModal({ match, predicting, diff --git a/frontend/src/pages/matches/components/OutcomePanel.tsx b/frontend/src/pages/matches/components/OutcomePanel.tsx new file mode 100644 index 0000000..0eb8dde --- /dev/null +++ b/frontend/src/pages/matches/components/OutcomePanel.tsx @@ -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 ( +
+
+
+ ) +} + +/** 胜平负一行文字:选中的红字加方块标记,未选中的退灰 */ +function OutcomeLine({ + pick, + confidence, +}: { + pick: string | null + confidence: number | null +}) { + const options = ['1', 'X', '2'] as const + return ( +
+
+ {options.map(o => { + const on = pick === o + return ( +
+ + {on && + {on && confidence !== null && ( + + 置信 {Math.round(confidence * 100)}% + + )} +
+ ) + })} +
+ {pick && confidence !== null && ( +
+ +

主观置信度,非统计概率

+
+ )} +
+ ) +} + +/** 预测成本展示:耗时 + 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 ( +
+
+ {latency && ( + + 耗时 {latency} + + )} + {tokens && ( + + prompt/completion: {tokens} + + )} + {prediction.rate_limit_remaining != null && prediction.rate_limit_remaining <= 3 && ( + + 剩余配额: {prediction.rate_limit_remaining}/10(分钟) + + )} +
+
+ ) +} + +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 && ( +
+

+ {prediction.pred_home_goals ?? '-'} + : + {prediction.pred_away_goals ?? '-'} +

+

预测比分

+ {prediction.alt_pred_home_goals != null && prediction.alt_pred_away_goals != null && ( +

+ 备选{' '} + + {prediction.alt_pred_home_goals}:{prediction.alt_pred_away_goals} + +

+ )} +
+ )} + + {!degraded && ( +
+ +
+ )} + + {!degraded && } + + ) +} diff --git a/frontend/src/pages/matches/components/ReasoningPanel.tsx b/frontend/src/pages/matches/components/ReasoningPanel.tsx new file mode 100644 index 0000000..07d1b61 --- /dev/null +++ b/frontend/src/pages/matches/components/ReasoningPanel.tsx @@ -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 ( +
+

降级原因

+
+

{prediction.reasoning}

+
+
+ ) + } + + // 成功态:reasoning 展示为「终裁意见」 + return ( +
+

终裁意见

+
+

{prediction.reasoning}

+
+
+ ) +} diff --git a/src/api/app.py b/src/api/app.py index 8630d51..4525476 100644 --- a/src/api/app.py +++ b/src/api/app.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +import os from collections.abc import AsyncIterator from contextlib import asynccontextmanager @@ -40,6 +41,24 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: "多 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 diff --git a/src/api/routes/predict.py b/src/api/routes/predict.py index eff6e84..3204df3 100644 --- a/src/api/routes/predict.py +++ b/src/api/routes/predict.py @@ -64,11 +64,9 @@ async def predict(req: PredictRequest, request: Request): raise HTTPException(500, "预测失败,请查看服务器日志") # D2: 三种模式统一返回 PredictResult —— 字段映射单一化,无 dict 分支。 - # 仅 baseline 的 prediction_id 需要在此落库补齐(服务层不落库)。 - if req.mode == "baseline": - prediction_id = await _persist_baseline(req.match_id, result) - else: - prediction_id = result.prediction_id + # P3-2:baseline 已在服务层(predict_baseline)落库并回填真实 prediction_id, + # 路由层不再需要特殊的 _persist_baseline,与 single/multi 路径统一。 + prediction_id = result.prediction_id # 3. 结果映射(无 DB 访问) logger.info( @@ -101,36 +99,6 @@ async def predict(req: PredictRequest, request: Request): ) -async def _persist_baseline(match_id: int, baseline: PredictResult) -> int: - """将基线预测结果写入 prediction 表,复用 upsert 语义。""" - from src.db.unit_of_work import get_uow - from src.llm.predict import _upsert_prediction - - async with get_uow() as session: - pred = await _upsert_prediction( - session, - match_id=match_id, - provider_name="baseline", - model="baseline", - mode="baseline", - run_type="live", # baseline 是 live 预测的变体,符合 ck_run_type_enum - values={ - "prompt_version": baseline.prompt_version, - "prompt_tokens": baseline.prompt_tokens or 0, - "completion_tokens": baseline.completion_tokens or 0, - "latency_ms": baseline.latency_ms or 0, - "pred_home_goals": baseline.pred_home_goals, - "pred_away_goals": baseline.pred_away_goals, - "pred_1x2": baseline.pred_1x2, - "subjective_confidence": baseline.subjective_confidence, - "reasoning": baseline.reasoning, - "raw_response": baseline.raw, - "status": "success", - }, - ) - return pred.id - - @router.get("/predictions", response_model=list[PredictionOut], dependencies=[Depends(require_admin)]) async def list_predictions( match_id: int | None = None, diff --git a/src/core/config.py b/src/core/config.py index 5be4b6e..fa6ef49 100644 --- a/src/core/config.py +++ b/src/core/config.py @@ -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 diff --git a/src/llm/baseline.py b/src/llm/baseline.py index d4aa690..1c3bc15 100644 --- a/src/llm/baseline.py +++ b/src/llm/baseline.py @@ -12,7 +12,7 @@ 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 +from src.llm.predict import PredictResult, _upsert_prediction logger = logging.getLogger(__name__) @@ -58,19 +58,23 @@ async def predict_baseline( 返回 PredictResult(D2 统一结果类型): provider=model="baseline", 不调用 LLM,latency_ms≈0。 - prediction_id 为占位 0 —— baseline 不在服务层落库, - 由路由层 _persist_baseline 落库后取得真实 id。 + + 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 @@ -93,8 +97,38 @@ async def predict_baseline( else: pred_1x2 = "X" + values = { + "prompt_version": "baseline_v1", + "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}。" + ), + "raw_response": {"home_avg": round(home_avg, 2), "away_avg": round(away_avg, 2)}, + "status": "success", + } + + # P3-2:服务层落库,回填真实 prediction_id(与 single/multi 统一)。 + async with get_uow() as session: + pred = await _upsert_prediction( + session, + match_id=match_id, + provider_name="baseline", + model="baseline", + mode="baseline", + run_type="live", + values=values, + ) + prediction_id = pred.id + return PredictResult( - prediction_id=0, # 占位:真实 id 由路由层 _persist_baseline 落库后返回 + prediction_id=prediction_id, provider="baseline", model="baseline", prompt_version="baseline_v1", @@ -105,14 +139,11 @@ async def predict_baseline( alt_pred_away_goals=None, pred_1x2=pred_1x2, subjective_confidence=0.5, - reasoning=( - f"基线估计(非投注建议): 主队主场场均进球 {home_avg:.2f} → 预测 {pred_home}; " - f"客队客场场均进球 {away_avg:.2f} → 预测 {pred_away}。" - ), + reasoning=values["reasoning"], context="", # baseline 不构建 LLM 上下文 status="success", latency_ms=0, prompt_tokens=0, completion_tokens=0, - raw={"home_avg": round(home_avg, 2), "away_avg": round(away_avg, 2)}, + raw=values["raw_response"], ) diff --git a/tests/test_baseline.py b/tests/test_baseline.py index 2eb7564..693cecf 100644 --- a/tests/test_baseline.py +++ b/tests/test_baseline.py @@ -5,6 +5,7 @@ """ from __future__ import annotations +from types import SimpleNamespace from unittest.mock import patch import pytest @@ -12,6 +13,24 @@ import pytest from src.llm.baseline import _avg_goals, predict_baseline +class _FakeUoW: + """P3-2:baseline 在服务层落库,测试需 mock get_uow。""" + + async def __aenter__(self): + return SimpleNamespace( + execute=lambda *a, **k: SimpleNamespace(scalar_one_or_none=lambda: None), + add=lambda *a, **k: None, + flush=lambda *a, **k: None, + ) + + async def __aexit__(self, *a): + return None + + +async def _fake_upsert(session, **kw): + return SimpleNamespace(id=1) + + @pytest.mark.asyncio async def test_avg_goals_no_data_returns_zero(): """无历史数据时场均进球为 0(不抛异常)。""" @@ -68,7 +87,9 @@ async def test_predict_baseline_no_llm(): match_status = "scheduled" with patch("src.llm.baseline._avg_goals", fake_avg), \ - patch("src.llm.baseline.AsyncSessionLocal") as SLC: + patch("src.llm.baseline.AsyncSessionLocal") as SLC, \ + patch("src.db.unit_of_work.get_uow", _FakeUoW), \ + patch("src.llm.baseline._upsert_prediction", _fake_upsert): class FakeSession: async def get(self, cls, mid): return FakeMatch() @@ -93,6 +114,8 @@ async def test_predict_baseline_no_llm(): assert result.pred_1x2 == "X" assert result.subjective_confidence == 0.5 assert "非投注建议" in result.reasoning + # P3-2:服务层落库,回填真实 prediction_id + assert result.prediction_id == 1 # 确认未调用任何 LLM 相关模块 assert "home_10" in captured and "away_20" in captured @@ -112,7 +135,9 @@ async def test_predict_baseline_clamps_to_range(): match_status = "scheduled" with patch("src.llm.baseline._avg_goals", fake_avg), \ - patch("src.llm.baseline.AsyncSessionLocal") as SLC: + patch("src.llm.baseline.AsyncSessionLocal") as SLC, \ + patch("src.db.unit_of_work.get_uow", _FakeUoW), \ + patch("src.llm.baseline._upsert_prediction", _fake_upsert): class FakeSession: async def get(self, cls, mid): return FakeMatch() @@ -128,3 +153,5 @@ async def test_predict_baseline_clamps_to_range(): assert result.pred_home_goals == 10.0 # clamped assert result.pred_away_goals == 0.0 # clamped assert result.pred_1x2 == "1" # 10:0 主胜 + # P3-2:服务层落库,回填真实 prediction_id + assert result.prediction_id == 1 diff --git a/tests/test_d2_predict_unification.py b/tests/test_d2_predict_unification.py index aa9a386..683a008 100644 --- a/tests/test_d2_predict_unification.py +++ b/tests/test_d2_predict_unification.py @@ -62,12 +62,35 @@ async def test_predict_baseline_returns_predict_result(): async def __aexit__(self, *a): return None + # P3-2:baseline 在服务层落库(get_uow + _upsert_prediction),需 mock 掉。 + class FakeUoW: + async def __aenter__(self): + return _make_session() + + async def __aexit__(self, *a): + return None + + captured = {} + + async def fake_upsert(session, **kw): + captured.update(kw) + return SimpleNamespace(id=77) + + # baseline.py 内部 from-import get_uow / _upsert_prediction,需 patch 真实来源模块。 with patch("src.llm.baseline._avg_goals", fake_avg), \ - patch("src.llm.baseline.AsyncSessionLocal") as SLC: + patch("src.llm.baseline.AsyncSessionLocal") as SLC, \ + patch("src.db.unit_of_work.get_uow", FakeUoW), \ + patch("src.llm.baseline._upsert_prediction", fake_upsert): SLC.return_value = FakeCM() result = await predict_baseline(1) + # P3-2:验证服务层落库被调用且属性映射正确 + assert captured["match_id"] == 1 + assert captured["provider_name"] == "baseline" + assert captured["run_type"] == "live" + assert captured["values"]["pred_home_goals"] == 2.0 + assert isinstance(result, PredictResult) assert result.mode == "baseline" assert result.provider == "baseline" @@ -144,15 +167,31 @@ def test_predict_route_has_no_dict_branch(): # ============================================================ -# 4. _persist_baseline 属性映射(baseline 落库语义不变) +# 4. P3-2:baseline 服务层落库属性映射(落库已从路由移到 baseline.py) # ============================================================ +class _FakeResult: + """支持 .scalar_one_or_none() 的最小假结果集。""" + + def __init__(self, items): + self._items = list(items) + + def scalars(self): + return self + + def all(self): + return self._items + + def scalar_one_or_none(self): + return self._items[0] if self._items else None + + class _FakeUoW: - """替代 get_uow 的最小上下文管理器。""" + """替代 get_uow 的最小上下文管理器(session.execute 是 async 的)。""" def __init__(self): - self.session = SimpleNamespace() + self.session = _make_session() async def __aenter__(self): return self.session @@ -160,44 +199,67 @@ class _FakeUoW: async def __aexit__(self, *a): return None + def __call__(self): + return self + + +def _make_session(existing=None): + """构造带 async execute / add / flush 的假 session。""" + sess = SimpleNamespace() + + async def execute(*a, **k): + return _FakeResult(existing or []) + + sess.execute = execute + sess.add = lambda *a, **k: None + + async def flush(*a, **k): + return None + + sess.flush = flush + return sess + @pytest.mark.asyncio -async def test_persist_baseline_maps_attributes(monkeypatch): +async def test_baseline_service_persists_with_correct_attributes(monkeypatch): + """P3-2:baseline 在服务层(predict_baseline)落库,属性映射与路由旧版一致。""" captured = {} async def fake_upsert(session, **kwargs): captured.update(kwargs) return SimpleNamespace(id=77) + class FakeMatch: + id = 1 + home_team_id = 10 + away_team_id = 20 + league_id = 1 + match_status = "scheduled" + + class FakeSession: + async def get(self, cls, mid): + return FakeMatch() + + class FakeSLC: + async def __aenter__(self): + return FakeSession() + + async def __aexit__(self, *a): + return None + + async def fake_avg(db, *, team_id, side, league_id, before): + return 2.0 if side == "home" else 1.0 + + monkeypatch.setattr("src.llm.baseline._avg_goals", fake_avg) + monkeypatch.setattr("src.llm.baseline.AsyncSessionLocal", FakeSLC) monkeypatch.setattr("src.db.unit_of_work.get_uow", lambda: _FakeUoW()) - monkeypatch.setattr("src.llm.predict._upsert_prediction", fake_upsert) + # baseline.py 模块级 import _upsert_prediction(第 15 行),需 patch baseline 模块属性 + monkeypatch.setattr("src.llm.baseline._upsert_prediction", fake_upsert) - from src.api.routes.predict import _persist_baseline + result = await predict_baseline(1) - baseline = PredictResult( - prediction_id=0, # baseline 不在服务层落库,由 _persist_baseline 落库后取得真实 id - provider="baseline", - model="baseline", - prompt_version="baseline_v1", - mode="baseline", - pred_home_goals=2.0, - pred_away_goals=1.0, - alt_pred_home_goals=None, - alt_pred_away_goals=None, - pred_1x2="1", - subjective_confidence=0.5, - reasoning="r", - context="", - status="success", - latency_ms=0, - prompt_tokens=0, - completion_tokens=0, - raw={"home_avg": 2.1, "away_avg": 1.4}, - ) - - pid = await _persist_baseline(1, baseline) - - assert pid == 77 + # 落库被调用且属性映射正确 + assert captured, f"predict_baseline 应调用 _upsert_prediction 落库,但 captured 为空(result.prediction_id={result.prediction_id!r})" assert captured["match_id"] == 1 assert captured["provider_name"] == "baseline" assert captured["model"] == "baseline" @@ -212,5 +274,28 @@ async def test_persist_baseline_maps_attributes(monkeypatch): assert v["prompt_tokens"] == 0 assert v["completion_tokens"] == 0 assert v["latency_ms"] == 0 - assert v["raw_response"] == {"home_avg": 2.1, "away_avg": 1.4} + assert v["raw_response"] == {"home_avg": 2.0, "away_avg": 1.0} assert v["status"] == "success" + + # 回填真实 prediction_id(服务层落库后取得) + assert result.prediction_id == 77 + assert result.pred_1x2 == "1" + assert captured["match_id"] == 1 + assert captured["provider_name"] == "baseline" + assert captured["model"] == "baseline" + assert captured["mode"] == "baseline" + assert captured["run_type"] == "live" + v = captured["values"] + assert v["prompt_version"] == "baseline_v1" + assert v["pred_home_goals"] == 2.0 + assert v["pred_away_goals"] == 1.0 + assert v["pred_1x2"] == "1" + assert v["subjective_confidence"] == 0.5 + assert v["prompt_tokens"] == 0 + assert v["completion_tokens"] == 0 + assert v["latency_ms"] == 0 + assert v["raw_response"] == {"home_avg": 2.0, "away_avg": 1.0} + assert v["status"] == "success" + + # 回填真实 prediction_id(服务层落库后取得) + assert result.prediction_id == 77