feat: 出图报错捕获真因 + 翻成前端友好提示(故事板)
老板/研发要求:有报错时把真因抓出来,做成前端友好提示。 - 捕获真实错误体(openai_compatible._raise_with_body):取代 raise_for_status()。 yunqi 的真因(moderation_blocked / safety_violations=[sexual])只在 body 里, 原来只剩状态行「400 Bad Request for url」→ 真因长期不可见。现在完整抓出。 - friendly_generation_error():把原始报错翻成中文友好提示(内容审核/超时/限流/ 凭证/参考图被拒…),前端直接展示;原始报错留 task.error_message 供研发排查。 - 故事板失败:友好提示存 shot.error_message;并撤回「重试 400」——审核拦截重试 也是白拦,改成快速失败+提示,引导用户改措辞(如避免「胸罩/内衣/抚摸/贴身」)。 - 前端:失败帧缩略图加红色「⚠失败」角标(hover 看原因),主视图显示完整友好提示; 不再灰着没反应。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,26 @@ def _downscale_ref_for_edit(data: bytes, content_type: str, max_edge: int = 2048
|
||||
return data, content_type
|
||||
|
||||
|
||||
def _raise_with_body(response: requests.Response) -> None:
|
||||
"""非 2xx 时抛出**含响应体**的错误。中转站(yunqi 等)的真实原因(如 OpenAI 风格
|
||||
{"error":{"code":"moderation_blocked","message":"...safety_violations=[sexual]..."}})只在 body 里;
|
||||
原来的 raise_for_status() 只带状态行「400 Bad Request for url」→ 真因长期不可见。这里把 body 一并抛出,
|
||||
供上层记日志 + 翻成前端友好提示。"""
|
||||
if response.ok:
|
||||
return
|
||||
detail = ""
|
||||
try:
|
||||
data = response.json()
|
||||
err = data.get("error") if isinstance(data, dict) else None
|
||||
if isinstance(err, dict):
|
||||
detail = " ".join(str(err.get(k)) for k in ("code", "message") if err.get(k)).strip()
|
||||
if not detail:
|
||||
detail = str(data)[:500]
|
||||
except Exception: # noqa: BLE001 — body 非 JSON / 解析失败:退回纯文本截断
|
||||
detail = (response.text or "")[:500]
|
||||
raise requests.HTTPError(f"{response.status_code} {detail}".strip(), response=response)
|
||||
|
||||
|
||||
class OpenAICompatibleProvider(VolcanoArkProvider):
|
||||
"""通用 OpenAI 兼容中转站适配器(tokenssr / yunqi / 任意 New-API 网关)。
|
||||
|
||||
@@ -80,7 +100,7 @@ class OpenAICompatibleProvider(VolcanoArkProvider):
|
||||
json=body,
|
||||
timeout=300,
|
||||
)
|
||||
response.raise_for_status()
|
||||
_raise_with_body(response)
|
||||
return response.json()
|
||||
|
||||
def image_edit(
|
||||
@@ -121,5 +141,5 @@ class OpenAICompatibleProvider(VolcanoArkProvider):
|
||||
data=data,
|
||||
timeout=300,
|
||||
)
|
||||
response.raise_for_status()
|
||||
_raise_with_body(response)
|
||||
return response.json()
|
||||
|
||||
@@ -1398,7 +1398,10 @@ def build_storyboard_frame_prompt_refs(project, segment, refs: list[dict], extra
|
||||
|
||||
def _is_transient_error(exc: Exception) -> bool:
|
||||
"""网络抖动/超时类瞬时错误(可重试),区别于内容审核拦截、参数非法等确定性失败。
|
||||
中转站(tokenssr 等)偶发 Read timeout / 连接重置会无谓掐掉单帧,这类才重试。"""
|
||||
中转站(tokenssr 等)偶发 Read timeout / 连接重置会无谓掐掉单帧,这类才重试。
|
||||
★ 不重试 400:实测故事板的 400 主因是 gpt-image-2 内容审核拦截(moderation_blocked,
|
||||
safety_violations=[sexual],如脚本写到「蕾丝胸罩/抚摸/贴身」)—— 同一画面/文案重试还是被拦,
|
||||
重试只会拖延报错、空耗调用。这类应「快速失败 + 友好提示」让用户改提示词,而非闷头重试。"""
|
||||
msg = str(exc).lower()
|
||||
return any(
|
||||
k in msg
|
||||
@@ -1407,6 +1410,25 @@ def _is_transient_error(exc: Exception) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def friendly_generation_error(raw: str) -> str:
|
||||
"""把模型/中转站的原始报错翻成给用户的中文友好提示(前端直接展示)。原始报错仍记进 AITask 供排查。
|
||||
覆盖:内容审核拦截 / 超时 / 限流 / 凭证 / 参考图被拒 等;命不中给通用兜底。"""
|
||||
s = (raw or "").lower()
|
||||
if any(k in s for k in ("moderation_blocked", "safety system", "safety_violation", "content_policy", "content policy")):
|
||||
return "画面或文案被内容审核拦截(疑似敏感内容)。请调整脚本措辞(如避免「胸罩 / 内衣 / 抚摸 / 贴身」等直白表述,改用「产品 / 包装展示」),或更换参考图后重试。"
|
||||
if any(k in s for k in ("timed out", "timeout", "read timed out")):
|
||||
return "生成超时,可能是网络波动或模型繁忙,请稍后重试。"
|
||||
if any(k in s for k in ("429", "too many requests", "rate limit", "forbidden", "403")):
|
||||
return "请求过于频繁,模型暂时限流,请稍候片刻再重试。"
|
||||
if any(k in s for k in ("api_key", "unauthorized", "401")):
|
||||
return "图像服务凭证异常,请联系管理员处理。"
|
||||
if any(k in s for k in ("invalid_image", "invalid image", "image_file")):
|
||||
return "参考图未被模型接受(可能格式/尺寸问题),请更换参考图后重试。"
|
||||
if any(k in s for k in ("400", "bad request")):
|
||||
return "生成请求被模型拒绝,请调整提示词或更换参考图后重试。"
|
||||
return "生成失败,请重试;若多次失败请联系技术支持。"
|
||||
|
||||
|
||||
def _call_image_with_retry(fn, *, attempts: int = 2, base_delay: float = 2.0):
|
||||
"""对一次出图网络调用做有界重试:仅瞬时错误重试(指数退避),确定性失败立即抛出。
|
||||
出图无副作用(失败=没拿到图),重试安全;成功一次即返回。
|
||||
@@ -1487,14 +1509,16 @@ def _storyboard_shot_worker(task_id, shot_id, user_id) -> None:
|
||||
|
||||
transaction.on_commit(lambda a=asset: submit_asset_for_review(a))
|
||||
except Exception as exc: # noqa: BLE001 — 失败回滚额度,标记任务+shot 失败供 poll 上报
|
||||
raw = str(exc)
|
||||
hint = friendly_generation_error(raw) # 翻成前端友好提示;原始报错(含 yunqi body)留在 task 供排查
|
||||
task.status = AITask.Status.FAILED
|
||||
task.error_message = str(exc)
|
||||
task.error_message = raw[:2000]
|
||||
task.completed_at = timezone.now()
|
||||
task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
|
||||
release_credit(reservation=reservation, reason=str(exc))
|
||||
# 重跑失败时保留旧 adopted_version(画面不丢),只把状态标 FAILED + 错误供前端显示
|
||||
release_credit(reservation=reservation, reason=raw[:200])
|
||||
# 重跑失败时保留旧 adopted_version(画面不丢),只把状态标 FAILED + 友好提示供前端显示
|
||||
StoryboardShot.objects.filter(id=shot.id).update(
|
||||
status=StoryboardShot.Status.FAILED, error_message=str(exc), updated_at=timezone.now())
|
||||
status=StoryboardShot.Status.FAILED, error_message=hint, updated_at=timezone.now())
|
||||
finally:
|
||||
connections.close_all() # 释放该线程的 DB 连接
|
||||
|
||||
|
||||
Reference in New Issue
Block a user