fix: harden online validation flows

This commit is contained in:
yangqianqian
2026-07-01 10:56:08 +08:00
parent da137f099f
commit 033ef89536
7 changed files with 142 additions and 15 deletions

View File

@@ -8,6 +8,7 @@ url 字段不接收token站固定自家站架构方案§二
import logging import logging
from typing import Annotated from typing import Annotated
import httpx
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from pydantic import BaseModel, field_validator from pydantic import BaseModel, field_validator
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -32,7 +33,8 @@ class CreateApiKeyRequest(BaseModel):
def provider_not_empty(cls, v: str) -> str: def provider_not_empty(cls, v: str) -> str:
if not v or not v.strip(): if not v or not v.strip():
raise ValueError("provider 不能为空") raise ValueError("provider 不能为空")
return v.strip().lower() provider = v.strip().lower()
return "apiports" if provider == "openai" else provider
@field_validator("api_key") @field_validator("api_key")
@classmethod @classmethod
@@ -49,14 +51,47 @@ class TestApiKeyRequest(CreateApiKeyRequest):
def _format_key(k: UserApiKey) -> dict: def _format_key(k: UserApiKey) -> dict:
"""只显 provider + key_last4不暴露余额/用量/encrypted_key红线""" """只显 provider + key_last4不暴露余额/用量/encrypted_key红线"""
provider = "apiports" if k.provider == "openai" else k.provider
return { return {
"id": k.id, "id": k.id,
"provider": k.provider, "provider": provider,
"key_last4": k.key_last4, "key_last4": k.key_last4,
"created_at": k.created_at.isoformat(), "created_at": k.created_at.isoformat(),
} }
def _provider_aliases(provider: str) -> list[str]:
if provider == "apiports":
return ["apiports", "openai"]
return [provider]
def _upstream_error_message(provider: str, exc: Exception) -> str:
if isinstance(exc, httpx.HTTPStatusError):
status = exc.response.status_code
try:
detail = exc.response.json()
except Exception:
detail = (exc.response.text or "")[:160]
if status in (401, 403):
hint = "Key 无效、权限不足或模型未开通"
elif status == 402:
hint = "账户余额不足或未开通计费"
elif status == 429:
hint = "请求过频或额度受限"
elif status >= 500:
hint = "中转站服务暂时不可用"
else:
hint = "中转站返回异常"
logger.warning("API key test failed provider=%s status=%s detail=%s", provider, status, detail)
return f"Key 测试失败:{provider} 返回 {status}{hint}"
if isinstance(exc, httpx.ReadTimeout):
return f"Key 测试失败:{provider} 在测试超时时间内无响应,请稍后重试或先保存后跑任务验证"
if isinstance(exc, httpx.ConnectError):
return f"Key 测试失败:无法连接 {provider} 中转站,请检查服务器网络或 Base URL"
return f"Key 测试失败:{type(exc).__name__}"
# ── 路由 ─────────────────────────────────────────────────── # ── 路由 ───────────────────────────────────────────────────
@router.get("") @router.get("")
def list_api_keys( def list_api_keys(
@@ -94,11 +129,12 @@ def create_api_key(
.filter( .filter(
UserApiKey.user_id == current_user.user_id, UserApiKey.user_id == current_user.user_id,
UserApiKey.workspace_id == current_user.workspace_id, UserApiKey.workspace_id == current_user.workspace_id,
UserApiKey.provider == body.provider, UserApiKey.provider.in_(_provider_aliases(body.provider)),
) )
.first() .first()
) )
if existing: if existing:
existing.provider = body.provider
existing.encrypted_key = encrypted existing.encrypted_key = encrypted
existing.key_last4 = last4 existing.key_last4 = last4
db.commit() db.commit()
@@ -126,13 +162,12 @@ async def test_api_key(
current_user: Annotated[CurrentUser, Depends(require_write_permission)], current_user: Annotated[CurrentUser, Depends(require_write_permission)],
): ):
"""用客户输入的明文 key 做一次轻量连通性测试,不落库。""" """用客户输入的明文 key 做一次轻量连通性测试,不落库。"""
import httpx
from app.core.config import get_settings from app.core.config import get_settings
from app.core.response import raise_business from app.core.response import raise_business
settings = get_settings() settings = get_settings()
provider = body.provider provider = body.provider
if provider in ("openai", "apiports"): if provider == "apiports":
base = (settings.APIPORTS_BASE_URL or settings.IMAGE_API_BASE).rstrip("/") base = (settings.APIPORTS_BASE_URL or settings.IMAGE_API_BASE).rstrip("/")
model = settings.MODEL_TEXT model = settings.MODEL_TEXT
elif provider == "codeproxy": elif provider == "codeproxy":
@@ -144,7 +179,7 @@ async def test_api_key(
raise_business("服务端未配置该 Provider 的 Base URL") raise_business("服务端未配置该 Provider 的 Base URL")
try: try:
async with httpx.AsyncClient(timeout=12) as client: async with httpx.AsyncClient(timeout=45) as client:
resp = await client.post( resp = await client.post(
f"{base}/chat/completions", f"{base}/chat/completions",
headers={"Authorization": f"Bearer {body.api_key}"}, headers={"Authorization": f"Bearer {body.api_key}"},
@@ -156,7 +191,7 @@ async def test_api_key(
) )
resp.raise_for_status() resp.raise_for_status()
except Exception as exc: except Exception as exc:
raise_business(f"Key 测试失败:{type(exc).__name__}") raise_business(_upstream_error_message(provider, exc))
logger.info("API key test ok: user=%s provider=%s", current_user.user_id, provider) logger.info("API key test ok: user=%s provider=%s", current_user.user_id, provider)
return ok({"ok": True, "provider": provider}) return ok({"ok": True, "provider": provider})

View File

@@ -165,6 +165,20 @@ def build_fission_prompt(
kw_parts = [x for x in ([name] + sp_words + ([audience] if audience != "未提供" else [])) if x and x != "未提供"] kw_parts = [x for x in ([name] + sp_words + ([audience] if audience != "未提供" else [])) if x and x != "未提供"]
kw_line = "".join(kw_parts) if kw_parts else "" kw_line = "".join(kw_parts) if kw_parts else ""
benchmark_refs = prod.get("benchmark_refs") or []
benchmark_lines: list[str] = []
for idx, ref in enumerate(benchmark_refs[:3], start=1):
if not isinstance(ref, dict):
continue
compact = []
for key in ("title_style", "cover_hook", "pain_point", "scene", "selling_angle", "visual_structure"):
val = ref.get(key)
if val:
compact.append(f"{key}:{val}")
if compact:
benchmark_lines.append(f"标杆配方{idx}" + "".join(compact))
benchmark_block = "\n".join(benchmark_lines) if benchmark_lines else "未选择或未分析完成"
# D3修复按套序循环分配 A/B/C 叙事主线 # D3修复按套序循环分配 A/B/C 叙事主线
narrative_keys = list(TEXT_NARRATIVE_BY_STRATEGY.keys()) # ["A","B","C"] narrative_keys = list(TEXT_NARRATIVE_BY_STRATEGY.keys()) # ["A","B","C"]
narrative_lines = [] narrative_lines = []
@@ -182,6 +196,7 @@ def build_fission_prompt(
产品卖点:{''.join(points) or '未提供'} 产品卖点:{''.join(points) or '未提供'}
目标人群:{audience} 目标人群:{audience}
关键词:{kw_line} 关键词:{kw_line}
标杆8维配方参考{benchmark_block}
裂变维度:{''.join(dims)} 裂变维度:{''.join(dims)}
爆款参考度:{strategy['level_label']}{strategy['prompt_rule']} 爆款参考度:{strategy['level_label']}{strategy['prompt_rule']}
生成数量:{note_count} 生成数量:{note_count}

View File

@@ -130,7 +130,7 @@ def execute_fission_pipeline(db: Session, clients, fission_id: int, source_task_
from app.models.fission import FissionTask, FissionNote from app.models.fission import FissionTask, FissionNote
from app.models.task import GenerationTask from app.models.task import GenerationTask
from app.models.product import Product from app.models.product import Product
from app.workers.pipeline_steps import build_product_dict from app.workers.pipeline_steps import build_product_dict, load_benchmark_features
from app.services.ai_engine.fission_prompt import build_fission_prompt from app.services.ai_engine.fission_prompt import build_fission_prompt
ft = db.query(FissionTask).filter(FissionTask.id == fission_id).first() ft = db.query(FissionTask).filter(FissionTask.id == fission_id).first()
@@ -150,6 +150,7 @@ def execute_fission_pipeline(db: Session, clients, fission_id: int, source_task_
return {"fission_id": fission_id, "status": "failed", "reason": "源任务或产品缺失"} return {"fission_id": fission_id, "status": "failed", "reason": "源任务或产品缺失"}
product = build_product_dict(product_row) product = build_product_dict(product_row)
product["benchmark_refs"] = load_benchmark_features(db, src, ft.workspace_id)
image_count = max(1, src.image_count or 3) image_count = max(1, src.image_count or 3)
banned = [] # 违禁词分级表后续接入;评分器内置 BANNED_WORDS_DEFAULT 已兜底 banned = [] # 违禁词分级表后续接入;评分器内置 BANNED_WORDS_DEFAULT 已兜底

View File

@@ -87,6 +87,29 @@ def create_generation_task(
# 第2环关联标杆笔记ID存库JSON list。pipeline 据此读 features_json 注入文案 prompt。 # 第2环关联标杆笔记ID存库JSON list。pipeline 据此读 features_json 注入文案 prompt。
import json import json
_bids = getattr(body, "benchmark_ids", None) or [] _bids = getattr(body, "benchmark_ids", None) or []
if _bids:
from app.models.product import BenchmarkNote
ids = [int(i) for i in _bids]
rows = (
db.query(BenchmarkNote)
.filter(
BenchmarkNote.id.in_(ids),
BenchmarkNote.workspace_id == current_user.workspace_id,
BenchmarkNote.product_id == body.product_id,
)
.all()
)
by_id = {int(b.id): b for b in rows}
missing = [i for i in ids if i not in by_id]
if missing:
raise_business(f"标杆笔记不存在或不属于当前产品:{missing[:3]}")
not_ready = [
i for i in ids
if by_id[i].analyze_status != "done" or by_id[i].analysis_source == "fallback"
]
if not_ready:
raise_business("所选标杆尚未分析完成,请先到系统管理里点击「分析标杆」")
benchmark_ids_json = json.dumps([int(i) for i in _bids]) if _bids else None benchmark_ids_json = json.dumps([int(i) for i in _bids]) if _bids else None
task = GenerationTask( task = GenerationTask(

View File

@@ -7,7 +7,7 @@ import { getErrorAction } from '@/types/errors';
export function ApiKeysTab() { export function ApiKeysTab() {
const [keys, setKeys] = useState<ApiKey[]>([]); const [keys, setKeys] = useState<ApiKey[]>([]);
const [form, setForm] = useState<CreateApiKeyRequest>({ provider: 'openai', api_key: '' }); const [form, setForm] = useState<CreateApiKeyRequest>({ provider: 'apiports', api_key: '' });
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [success, setSuccess] = useState(''); const [success, setSuccess] = useState('');
@@ -16,7 +16,14 @@ export function ApiKeysTab() {
const keyInputRef = useRef<HTMLInputElement>(null); const keyInputRef = useRef<HTMLInputElement>(null);
// 当前选中 provider 是否已录过 → 决定是「保存」还是「更新覆盖」 // 当前选中 provider 是否已录过 → 决定是「保存」还是「更新覆盖」
const isOverwrite = keys.some((k) => k.provider === form.provider); const providerOf = (provider: string) => provider === 'openai' ? 'apiports' : provider;
const providerLabel = (provider: string) => {
const normalized = providerOf(provider);
if (normalized === 'apiports') return '主通道 apiports';
if (normalized === 'codeproxy') return '备用通道 codeproxy';
return normalized;
};
const isOverwrite = keys.some((k) => providerOf(k.provider) === form.provider);
useEffect(() => { loadKeys(); }, []); useEffect(() => { loadKeys(); }, []);
@@ -30,7 +37,7 @@ export function ApiKeysTab() {
// 点列表「修改」:把该 provider 填回表单、清空 key 待重录、聚焦输入框(覆盖式改 key // 点列表「修改」:把该 provider 填回表单、清空 key 待重录、聚焦输入框(覆盖式改 key
function handleEdit(provider: string) { function handleEdit(provider: string) {
setForm({ provider, api_key: '' }); setForm({ provider: providerOf(provider), api_key: '' });
setError(''); setError('');
setSuccess(''); setSuccess('');
keyInputRef.current?.focus(); keyInputRef.current?.focus();
@@ -42,7 +49,7 @@ export function ApiKeysTab() {
setSaving(true); setError(''); setSuccess(''); setSaving(true); setError(''); setSuccess('');
try { try {
await api.post('/api/v1/api-keys', form); await api.post('/api/v1/api-keys', form);
setForm({ provider: 'openai', api_key: '' }); setForm({ provider: 'apiports', api_key: '' });
await loadKeys(); await loadKeys();
} catch (err) { } catch (err) {
const apiErr = err as ApiError; const apiErr = err as ApiError;
@@ -81,7 +88,7 @@ export function ApiKeysTab() {
<select id="provider" value={form.provider} <select id="provider" value={form.provider}
onChange={(e) => { setError(''); setSuccess(''); setForm((f) => ({ ...f, provider: e.target.value })); }} onChange={(e) => { setError(''); setSuccess(''); setForm((f) => ({ ...f, provider: e.target.value })); }}
className="border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange"> className="border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange">
<option value="openai"> apiports+</option> <option value="apiports"> apiports+</option>
<option value="codeproxy"> codeproxyapiports </option> <option value="codeproxy"> codeproxyapiports </option>
<option value="gemini">Gemini</option> <option value="gemini">Gemini</option>
</select> </select>
@@ -115,7 +122,7 @@ export function ApiKeysTab() {
{keys.map((k) => ( {keys.map((k) => (
<li key={k.id} className="flex items-center justify-between py-3 text-sm"> <li key={k.id} className="flex items-center justify-between py-3 text-sm">
<span> <span>
<span className="font-medium">{k.provider}</span> <span className="font-medium">{providerLabel(k.provider)}</span>
<span className="text-text-secondary ml-2">{k.key_last4}</span> <span className="text-text-secondary ml-2">{k.key_last4}</span>
</span> </span>
<span className="flex items-center gap-3"> <span className="flex items-center gap-3">

View File

@@ -20,6 +20,8 @@ export function BenchmarksTab() {
const [benchmarks, setBenchmarks] = useState<Benchmark[]>([]); const [benchmarks, setBenchmarks] = useState<Benchmark[]>([]);
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [analyzingId, setAnalyzingId] = useState<number | null>(null);
const [error, setError] = useState('');
useEffect(() => { loadProducts(); }, []); useEffect(() => { loadProducts(); }, []);
@@ -38,16 +40,35 @@ export function BenchmarksTab() {
async function loadBenchmarks(productId: number) { async function loadBenchmarks(productId: number) {
setLoading(true); setLoading(true);
setError('');
try { try {
const data = await api.get<PaginatedResponse<Benchmark>>( const data = await api.get<PaginatedResponse<Benchmark>>(
`/api/v1/products/${productId}/benchmarks` `/api/v1/products/${productId}/benchmarks`
); );
setBenchmarks(data.items); setBenchmarks(data.items);
} catch (err) {
const e = err as { message?: string };
setError(e.message || '标杆笔记加载失败');
} finally { } finally {
setLoading(false); setLoading(false);
} }
} }
async function analyzeBenchmark(id: number) {
if (!selectedProductId) return;
setAnalyzingId(id);
setError('');
try {
await api.post(`/api/v1/benchmarks/${id}/analyze`, {});
await loadBenchmarks(selectedProductId);
} catch (err) {
const e = err as { message?: string };
setError(e.message || '标杆分析失败,请检查 API Key 后重试');
} finally {
setAnalyzingId(null);
}
}
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -70,6 +91,8 @@ export function BenchmarksTab() {
{loading ? ( {loading ? (
<div className="text-text-secondary text-sm" aria-busy="true"></div> <div className="text-text-secondary text-sm" aria-busy="true"></div>
) : ( ) : (
<>
{error && <div role="alert" className="rounded bg-red-50 px-3 py-2 text-sm text-status-error">{error}</div>}
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4">
{benchmarks.map((b) => ( {benchmarks.map((b) => (
<div key={b.id} className="border border-border-default rounded-xl overflow-hidden bg-surface-primary"> <div key={b.id} className="border border-border-default rounded-xl overflow-hidden bg-surface-primary">
@@ -109,6 +132,15 @@ export function BenchmarksTab() {
<a href={b.link_url} target="_blank" rel="noopener noreferrer" <a href={b.link_url} target="_blank" rel="noopener noreferrer"
className="text-xs text-brand-orange hover:underline mt-1 block"></a> className="text-xs text-brand-orange hover:underline mt-1 block"></a>
)} )}
<button type="button" onClick={() => analyzeBenchmark(b.id)}
disabled={analyzingId === b.id || b.analyze_status === 'analyzing'}
className="mt-3 rounded-lg border border-border-default px-3 py-1.5 text-xs text-text-secondary hover:border-brand-orange hover:text-brand-orange disabled:opacity-50">
{analyzingId === b.id || b.analyze_status === 'analyzing'
? '分析中…'
: b.analyze_status === 'done'
? '重新分析'
: '分析标杆'}
</button>
</div> </div>
</div> </div>
))} ))}
@@ -118,6 +150,7 @@ export function BenchmarksTab() {
</p> </p>
)} )}
</div> </div>
</>
)} )}
{showForm && selectedProductId && ( {showForm && selectedProductId && (

View File

@@ -55,7 +55,20 @@ async function request<T>(
Object.assign(headers, options.headers as Record<string, string>); Object.assign(headers, options.headers as Record<string, string>);
if (token) headers['Authorization'] = `Bearer ${token}`; if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`${BASE_URL}${path}`, { ...options, headers }); const url = `${BASE_URL}${path}`;
const res = await fetch(url, { ...options, headers });
const contentType = res.headers.get('content-type') || '';
if (!contentType.includes('application/json')) {
const text = await res.text();
const apiError = new Error(
`接口返回非 JSON(${res.status}),请刷新后重试;如果仍出现,请联系管理员检查后端代理。`
) as Error & { code?: number; status?: number; responseText?: string; url?: string };
apiError.code = res.status || 50001;
apiError.status = res.status;
apiError.responseText = text.slice(0, 160);
apiError.url = url;
throw apiError;
}
const body: ApiResponse<T> | ApiError = await res.json(); const body: ApiResponse<T> | ApiError = await res.json();
if ('code' in body && body.code === ERROR_CODES.SUCCESS) { if ('code' in body && body.code === ERROR_CODES.SUCCESS) {