mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 15:34:16 +08:00
问题(Issue #8): AI API 反代到 Cloudflare 后,OpenAI SDK 默认 UA (OpenAI/Python) 被 Bot Fight Mode 拦截,报 'Your request was blocked'。 方案: 后端始终发送浏览器风格 UA,默认内置 Chrome UA(开箱即用); AI 设置页提供开关 + 随机生成/手动粘贴的自定义 UA。 后端(config.py / settings.py / ai_generator.py / strategy.py): - 新增 ai_user_agent 配置项,默认桌面 Chrome UA - 两处 OpenAI client 创建均注入 default_headers={User-Agent} (ai_generator 实际生成 + strategy 测试连通性) - 空 UA 自动回退内置默认,保证默认状态即绕过拦截 前端(api.ts / AI.tsx): - 自定义 UA 开关:默认关闭(用内置默认),开启才显示输入框 - 随机生成按钮: 产出近期 Chrome 桌面 UA(Win/Mac/Linux 随机) - 已有自定义 UA 时开关默认开启 不影响直连官方 OpenAI/DeepSeek 的用户(官方不校验 UA)。
This commit is contained in:
@@ -58,6 +58,7 @@ def get_settings() -> dict:
|
||||
"has_ai_key": bool(secrets_store.get_ai_key()),
|
||||
"ai_model": secrets_store.get_ai_config("ai_model", settings.ai_model),
|
||||
"ai_daily_token_budget": int(secrets_store.get_ai_config("ai_daily_token_budget", str(settings.ai_daily_token_budget)) or settings.ai_daily_token_budget),
|
||||
"ai_user_agent": secrets_store.get_ai_config("ai_user_agent", settings.ai_user_agent),
|
||||
}
|
||||
|
||||
|
||||
@@ -212,6 +213,7 @@ class AiSettingsIn(BaseModel):
|
||||
api_key: str | None = None
|
||||
model: str = ""
|
||||
daily_token_budget: int = 500_000
|
||||
user_agent: str = ""
|
||||
|
||||
|
||||
@router.post("/ai")
|
||||
@@ -238,6 +240,9 @@ def save_ai_settings(req: AiSettingsIn) -> dict:
|
||||
settings.ai_model = req.model
|
||||
updates["ai_daily_token_budget"] = req.daily_token_budget
|
||||
settings.ai_daily_token_budget = req.daily_token_budget
|
||||
# user_agent 允许清空(回到默认浏览器 UA),故无条件持久化
|
||||
updates["ai_user_agent"] = req.user_agent
|
||||
settings.ai_user_agent = req.user_agent
|
||||
|
||||
if updates:
|
||||
secrets_store.save(updates)
|
||||
|
||||
@@ -324,7 +324,12 @@ async def ai_test(request: Request):
|
||||
return {"ok": False, "error": "未配置 API Key"}
|
||||
|
||||
try:
|
||||
client = AsyncOpenAI(api_key=ai_key, base_url=settings.ai_base_url)
|
||||
# User-Agent: 默认浏览器标识,绕过 Cloudflare 等 CDN/WAF 的 Bot 拦截(Issue #8)。
|
||||
client = AsyncOpenAI(
|
||||
api_key=ai_key,
|
||||
base_url=settings.ai_base_url,
|
||||
default_headers={"User-Agent": settings.ai_user_agent or "Mozilla/5.0"},
|
||||
)
|
||||
resp = await client.chat.completions.create(
|
||||
model=settings.ai_model,
|
||||
messages=[{"role": "user", "content": "回复 OK"}],
|
||||
|
||||
@@ -71,6 +71,13 @@ class Settings(BaseSettings):
|
||||
ai_api_key: str = ""
|
||||
ai_model: str = "gpt-5.5"
|
||||
ai_daily_token_budget: int = 5_000_000
|
||||
# 默认浏览器风格 UA,绕过 Cloudflare 等 CDN/WAF 的 Bot 拦截(Issue #8)。
|
||||
# 用户可在 AI 设置页按需修改。
|
||||
ai_user_agent: str = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/131.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
# Server
|
||||
host: str = "0.0.0.0"
|
||||
|
||||
@@ -83,11 +83,17 @@ class AIStrategyGenerator:
|
||||
if not ai_key:
|
||||
raise RuntimeError("AI API Key 未配置,请在设置页面配置")
|
||||
|
||||
# User-Agent: 默认浏览器标识,绕过 Cloudflare 等 CDN/WAF 的 Bot 拦截(Issue #8)。
|
||||
# 用户可在 AI 设置页自定义。
|
||||
from app.config import settings
|
||||
user_agent = secrets_store.get_ai_config("ai_user_agent", "") or settings.ai_user_agent
|
||||
|
||||
client = AsyncOpenAI(
|
||||
api_key=ai_key,
|
||||
base_url=secrets_store.get_ai_config("ai_base_url", "https://api.alysc.top"),
|
||||
timeout=180.0,
|
||||
max_retries=2,
|
||||
default_headers={"User-Agent": user_agent},
|
||||
)
|
||||
# 使用流式请求:CDN 收到首个 token 后会持续转发,不会因等待超时
|
||||
stream = await client.chat.completions.create(
|
||||
|
||||
@@ -566,6 +566,7 @@ export interface SettingsState {
|
||||
has_ai_key: boolean
|
||||
ai_model: string
|
||||
ai_daily_token_budget: number
|
||||
ai_user_agent: string
|
||||
}
|
||||
|
||||
/** 保存 TickFlow Key 的响应(先探后存) */
|
||||
@@ -636,7 +637,7 @@ export const api = {
|
||||
),
|
||||
|
||||
/** 保存 AI 配置 */
|
||||
saveAiSettings: (ai: { provider?: string; base_url?: string; api_key?: string; model?: string; daily_token_budget?: number }) =>
|
||||
saveAiSettings: (ai: { provider?: string; base_url?: string; api_key?: string; model?: string; daily_token_budget?: number; user_agent?: string }) =>
|
||||
request<{ ok: boolean }>('/api/settings/ai', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(ai),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Save, Loader2, Check, Wifi, WifiOff, Eye, EyeOff, Shield } from 'lucide-react'
|
||||
import { Save, Loader2, Check, Wifi, WifiOff, Eye, EyeOff, Shield, Shuffle } from 'lucide-react'
|
||||
import { useSettings } from '@/lib/useSharedQueries'
|
||||
import { api } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
@@ -21,6 +21,10 @@ export function SettingsAIPanel() {
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [model, setModel] = useState('')
|
||||
const [tokenBudget, setTokenBudget] = useState(5_000_000)
|
||||
// 自定义 User-Agent 开关:关闭 → 后端用内置默认浏览器 UA(开箱绕过 CDN 拦截);
|
||||
// 开启 → 用下方文本框的 UA,留空时随机生成。
|
||||
const [customUa, setCustomUa] = useState(false)
|
||||
const [userAgent, setUserAgent] = useState('')
|
||||
const [showKey, setShowKey] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
@@ -28,17 +32,35 @@ export function SettingsAIPanel() {
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [testResult, setTestResult] = useState<{ ok: boolean; msg: string } | null>(null)
|
||||
|
||||
// 随机生成一个近期桌面端 Chrome UA(Win/Mac/Linux 随机)
|
||||
const genRandomUa = () => {
|
||||
const major = 128 + Math.floor(Math.random() * 8) // 128~135
|
||||
const platforms = [
|
||||
`Windows NT 10.0; Win64; x64`,
|
||||
`Macintosh; Intel Mac OS X 10_15_7`,
|
||||
`X11; Linux x86_64`,
|
||||
]
|
||||
const pf = platforms[Math.floor(Math.random() * platforms.length)]
|
||||
setUserAgent(`Mozilla/5.0 (${pf}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${major}.0.0.0 Safari/537.36`)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!s) return
|
||||
setProvider(s.ai_provider ?? 'openai_compat')
|
||||
setBaseUrl(s.ai_base_url ?? '')
|
||||
setModel(s.ai_model ?? '')
|
||||
setTokenBudget(s.ai_daily_token_budget ?? 500_000)
|
||||
// 有已保存的自定义 UA → 开关默认开启;否则关闭(用后端内置默认)
|
||||
const ua = s.ai_user_agent ?? ''
|
||||
setCustomUa(!!ua)
|
||||
setUserAgent(ua)
|
||||
}, [s])
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => api.saveAiSettings({
|
||||
provider, base_url: baseUrl, api_key: apiKey || undefined, model, daily_token_budget: tokenBudget,
|
||||
// 关闭开关 → 提交空串,后端回退内置默认 UA
|
||||
user_agent: customUa ? userAgent : '',
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setSaved(true); setApiKey(''); qc.invalidateQueries({ queryKey: QK.settings })
|
||||
@@ -50,7 +72,7 @@ export function SettingsAIPanel() {
|
||||
setTesting(true); setTestResult(null)
|
||||
try {
|
||||
// 先保存当前配置(不保存 Key 仅用于测试时临时存)
|
||||
if (apiKey) await api.saveAiSettings({ provider, base_url: baseUrl, api_key: apiKey, model, daily_token_budget: tokenBudget })
|
||||
if (apiKey) await api.saveAiSettings({ provider, base_url: baseUrl, api_key: apiKey, model, daily_token_budget: tokenBudget, user_agent: customUa ? userAgent : '' })
|
||||
const r = await api.strategyAiTest()
|
||||
setTestResult({ ok: r.ok, msg: r.ok ? `连通成功 · 模型: ${r.model}${r.usage ? ` · 消耗 ${r.usage.prompt + r.usage.completion} tokens` : ''}` : (r.error ?? '未知错误') })
|
||||
} catch (e: any) {
|
||||
@@ -178,6 +200,39 @@ export function SettingsAIPanel() {
|
||||
<span className="text-[10px] text-muted">超出后仅发出提醒,不阻止 AI 调用</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 请求头 User-Agent */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] text-muted/50 uppercase tracking-wider">自定义请求头 User-Agent</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCustomUa(v => !v)}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full shrink-0 transition-colors duration-200 ${customUa ? 'bg-accent' : 'bg-elevated'}`}
|
||||
aria-pressed={customUa}
|
||||
>
|
||||
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform duration-200 ${customUa ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted/70 leading-relaxed">
|
||||
{customUa
|
||||
? '当前使用下方自定义 UA 调用 AI API。'
|
||||
: '默认已使用内置浏览器标识,可绕过 Cloudflare 等 CDN/WAF 拦截。仅在默认标识被拦截时才需开启自定义。'}
|
||||
</div>
|
||||
{customUa && (
|
||||
<div className="flex gap-2">
|
||||
<input type="text" value={userAgent} onChange={e => setUserAgent(e.target.value)}
|
||||
placeholder="留空点击「随机生成」或直接粘贴浏览器 UA"
|
||||
className="flex-1 h-8 px-2.5 rounded-lg bg-base border-0 ring-1 ring-border/30 text-xs font-mono text-foreground placeholder:text-muted/30 focus:outline-none focus:ring-2 focus:ring-accent/30 transition-shadow" />
|
||||
<button type="button" onClick={() => { if (!userAgent) genRandomUa() }}
|
||||
title={userAgent ? '已存在内容,清空后可重新生成' : '随机生成浏览器 UA'}
|
||||
className="h-8 px-2.5 rounded-lg border border-border/50 text-xs text-secondary hover:text-accent hover:border-accent/30 transition-all flex items-center gap-1.5 shrink-0 disabled:opacity-40"
|
||||
disabled={!!userAgent}>
|
||||
<Shuffle className="h-3 w-3" /> 随机
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user