From 033ef89536e00f1751365d684003a4765240092c Mon Sep 17 00:00:00 2001 From: yangqianqian <5845211314@qq.com> Date: Wed, 1 Jul 2026 10:56:08 +0800 Subject: [PATCH] fix: harden online validation flows --- backend/app/api/v1/api_keys.py | 49 ++++++++++++++++--- .../app/services/ai_engine/fission_prompt.py | 15 ++++++ backend/app/services/fission_pipeline.py | 3 +- backend/app/services/task_service.py | 23 +++++++++ frontend/src/components/config/ApiKeysTab.tsx | 19 ++++--- .../src/components/config/BenchmarksTab.tsx | 33 +++++++++++++ frontend/src/lib/api.ts | 15 +++++- 7 files changed, 142 insertions(+), 15 deletions(-) diff --git a/backend/app/api/v1/api_keys.py b/backend/app/api/v1/api_keys.py index 5479204..be441b4 100644 --- a/backend/app/api/v1/api_keys.py +++ b/backend/app/api/v1/api_keys.py @@ -8,6 +8,7 @@ url 字段不接收,token站固定自家站(架构方案§二)。 import logging from typing import Annotated +import httpx from fastapi import APIRouter, Depends from pydantic import BaseModel, field_validator from sqlalchemy.orm import Session @@ -32,7 +33,8 @@ class CreateApiKeyRequest(BaseModel): def provider_not_empty(cls, v: str) -> str: if not v or not v.strip(): raise ValueError("provider 不能为空") - return v.strip().lower() + provider = v.strip().lower() + return "apiports" if provider == "openai" else provider @field_validator("api_key") @classmethod @@ -49,14 +51,47 @@ class TestApiKeyRequest(CreateApiKeyRequest): def _format_key(k: UserApiKey) -> dict: """只显 provider + key_last4,不暴露余额/用量/encrypted_key(红线)。""" + provider = "apiports" if k.provider == "openai" else k.provider return { "id": k.id, - "provider": k.provider, + "provider": provider, "key_last4": k.key_last4, "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("") def list_api_keys( @@ -94,11 +129,12 @@ def create_api_key( .filter( UserApiKey.user_id == current_user.user_id, UserApiKey.workspace_id == current_user.workspace_id, - UserApiKey.provider == body.provider, + UserApiKey.provider.in_(_provider_aliases(body.provider)), ) .first() ) if existing: + existing.provider = body.provider existing.encrypted_key = encrypted existing.key_last4 = last4 db.commit() @@ -126,13 +162,12 @@ async def test_api_key( current_user: Annotated[CurrentUser, Depends(require_write_permission)], ): """用客户输入的明文 key 做一次轻量连通性测试,不落库。""" - import httpx from app.core.config import get_settings from app.core.response import raise_business settings = get_settings() provider = body.provider - if provider in ("openai", "apiports"): + if provider == "apiports": base = (settings.APIPORTS_BASE_URL or settings.IMAGE_API_BASE).rstrip("/") model = settings.MODEL_TEXT elif provider == "codeproxy": @@ -144,7 +179,7 @@ async def test_api_key( raise_business("服务端未配置该 Provider 的 Base URL") try: - async with httpx.AsyncClient(timeout=12) as client: + async with httpx.AsyncClient(timeout=45) as client: resp = await client.post( f"{base}/chat/completions", headers={"Authorization": f"Bearer {body.api_key}"}, @@ -156,7 +191,7 @@ async def test_api_key( ) resp.raise_for_status() 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) return ok({"ok": True, "provider": provider}) diff --git a/backend/app/services/ai_engine/fission_prompt.py b/backend/app/services/ai_engine/fission_prompt.py index 6fbe02c..dd9bf68 100644 --- a/backend/app/services/ai_engine/fission_prompt.py +++ b/backend/app/services/ai_engine/fission_prompt.py @@ -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_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 叙事主线 narrative_keys = list(TEXT_NARRATIVE_BY_STRATEGY.keys()) # ["A","B","C"] narrative_lines = [] @@ -182,6 +196,7 @@ def build_fission_prompt( 产品卖点:{'、'.join(points) or '未提供'} 目标人群:{audience} 关键词:{kw_line} +标杆8维配方参考:{benchmark_block} 裂变维度:{'、'.join(dims)} 爆款参考度:{strategy['level_label']}。{strategy['prompt_rule']} 生成数量:{note_count}套 diff --git a/backend/app/services/fission_pipeline.py b/backend/app/services/fission_pipeline.py index 2e2353f..e15efa1 100644 --- a/backend/app/services/fission_pipeline.py +++ b/backend/app/services/fission_pipeline.py @@ -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.task import GenerationTask 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 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": "源任务或产品缺失"} 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) banned = [] # 违禁词分级表后续接入;评分器内置 BANNED_WORDS_DEFAULT 已兜底 diff --git a/backend/app/services/task_service.py b/backend/app/services/task_service.py index 5744c33..bd60d7f 100644 --- a/backend/app/services/task_service.py +++ b/backend/app/services/task_service.py @@ -87,6 +87,29 @@ def create_generation_task( # 第2环:关联标杆笔记ID存库(JSON list)。pipeline 据此读 features_json 注入文案 prompt。 import json _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 task = GenerationTask( diff --git a/frontend/src/components/config/ApiKeysTab.tsx b/frontend/src/components/config/ApiKeysTab.tsx index 53350db..5e77fc4 100644 --- a/frontend/src/components/config/ApiKeysTab.tsx +++ b/frontend/src/components/config/ApiKeysTab.tsx @@ -7,7 +7,7 @@ import { getErrorAction } from '@/types/errors'; export function ApiKeysTab() { const [keys, setKeys] = useState([]); - const [form, setForm] = useState({ provider: 'openai', api_key: '' }); + const [form, setForm] = useState({ provider: 'apiports', api_key: '' }); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const [success, setSuccess] = useState(''); @@ -16,7 +16,14 @@ export function ApiKeysTab() { const keyInputRef = useRef(null); // 当前选中 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(); }, []); @@ -30,7 +37,7 @@ export function ApiKeysTab() { // 点列表「修改」:把该 provider 填回表单、清空 key 待重录、聚焦输入框(覆盖式改 key) function handleEdit(provider: string) { - setForm({ provider, api_key: '' }); + setForm({ provider: providerOf(provider), api_key: '' }); setError(''); setSuccess(''); keyInputRef.current?.focus(); @@ -42,7 +49,7 @@ export function ApiKeysTab() { setSaving(true); setError(''); setSuccess(''); try { await api.post('/api/v1/api-keys', form); - setForm({ provider: 'openai', api_key: '' }); + setForm({ provider: 'apiports', api_key: '' }); await loadKeys(); } catch (err) { const apiErr = err as ApiError; @@ -81,7 +88,7 @@ export function ApiKeysTab() { @@ -115,7 +122,7 @@ export function ApiKeysTab() { {keys.map((k) => (
  • - {k.provider} + {providerLabel(k.provider)} …{k.key_last4} diff --git a/frontend/src/components/config/BenchmarksTab.tsx b/frontend/src/components/config/BenchmarksTab.tsx index 123e607..32640fc 100644 --- a/frontend/src/components/config/BenchmarksTab.tsx +++ b/frontend/src/components/config/BenchmarksTab.tsx @@ -20,6 +20,8 @@ export function BenchmarksTab() { const [benchmarks, setBenchmarks] = useState([]); const [showForm, setShowForm] = useState(false); const [loading, setLoading] = useState(false); + const [analyzingId, setAnalyzingId] = useState(null); + const [error, setError] = useState(''); useEffect(() => { loadProducts(); }, []); @@ -38,16 +40,35 @@ export function BenchmarksTab() { async function loadBenchmarks(productId: number) { setLoading(true); + setError(''); try { const data = await api.get>( `/api/v1/products/${productId}/benchmarks` ); setBenchmarks(data.items); + } catch (err) { + const e = err as { message?: string }; + setError(e.message || '标杆笔记加载失败'); } finally { 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 (
    @@ -70,6 +91,8 @@ export function BenchmarksTab() { {loading ? (
    加载中…
    ) : ( + <> + {error &&
    {error}
    }
    {benchmarks.map((b) => (
    @@ -109,6 +132,15 @@ export function BenchmarksTab() { 查看原帖 )} +
    ))} @@ -118,6 +150,7 @@ export function BenchmarksTab() {

    )}
    + )} {showForm && selectedProductId && ( diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 9b79e7c..405201e 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -55,7 +55,20 @@ async function request( Object.assign(headers, options.headers as Record); 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 | ApiError = await res.json(); if ('code' in body && body.code === ERROR_CODES.SUCCESS) {