diff --git a/apps/worker/worker_app/tasks/viral_video.py b/apps/worker/worker_app/tasks/viral_video.py index c581edba0..b02a6d4a7 100644 --- a/apps/worker/worker_app/tasks/viral_video.py +++ b/apps/worker/worker_app/tasks/viral_video.py @@ -1162,6 +1162,7 @@ def _step_render(job: ViralVideoJob, copy_result: dict, tts_audio_url: str | Non ) logger.info("[爆款视频] Seedance prompt (前300字): %s", prompt[:300]) + # 第一次调用:带参考图/首帧/音频/参考视频 result = call_video_generation( prompt=prompt, image_url=first_image, @@ -1175,8 +1176,58 @@ def _step_render(job: ViralVideoJob, copy_result: dict, tts_audio_url: str | Non reference_audios=ref_audios, reference_videos=ref_videos, ) + + # 肖像/内容审核拦截降级重试:去掉参考图/参考视频走纯文字 t2v 再试一次 + # (不影响音频——去掉首帧和多参考图通常就能绕过真人照片审核,纯文字描述真人是允许的) + def _check_and_reraise(result): + if result and isinstance(result, dict): + return result + from packages.shared.ai_service import get_last_video_error + + err = get_last_video_error() or {} + user_msg = err.get("user_message") or "" + detail = err.get("detail") or "" + err_code = err.get("error_code") or "unknown" + status_code = err.get("status_code", 0) + provider = _mcfg.get("provider", "doubao") + err_msg = user_msg or f"视频生成失败({provider} status={status_code} code={err_code})" + logger.error( + "[爆款视频] 视频生成失败: provider=%s model=%s code=%s status=%s user_msg=%s detail=%s", + provider, + model or "default", + err_code, + status_code, + user_msg, + (detail or "")[:500], + ) + raise RuntimeError(err_msg) + if not result or not isinstance(result, dict): - raise RuntimeError("Seedance 视频生成失败:返回为空") + from packages.shared.ai_service import get_last_video_error + + err = get_last_video_error() or {} + err_code = err.get("error_code") or "unknown" + is_portrait = err_code == "portrait_intercept" or any( + k in (err.get("detail", "") or "").lower() + for k in ("real person", "privacy", "portrait", "sensitivecontent", "face", "人脸", "肖像", "真人") + ) + if is_portrait and (first_image or rest_images or ref_videos): + logger.warning("[爆款视频] 参考素材触发真人/肖像审核拦截(error_code=%s),降级为纯文字t2v重试一次", err_code) + # 去掉所有参考图/参考视频,纯 t2v 泛化生成;音频仍保留(不影响肖像审核) + result = call_video_generation( + prompt=prompt, + image_url=None, + duration=dur, + ratio=ratio, + resolution=resolution, + output_dir=str(tmpdir), + model=model, + generate_audio=gen_audio, + reference_images=[], + reference_audios=ref_audios, + reference_videos=[], + ) + _check_and_reraise(result) video_path = result.get("video_path") or "" usage = result.get("usage") if not video_path or not Path(video_path).exists() or Path(video_path).stat().st_size == 0: diff --git a/packages/shared/ai_client.py b/packages/shared/ai_client.py index 5718e499c..2dba5cc5c 100755 --- a/packages/shared/ai_client.py +++ b/packages/shared/ai_client.py @@ -22,6 +22,15 @@ import httpx from packages.shared.config import get_shared_settings +# 网络/超时类异常父类集合:覆盖 Timeout/Connect/Network/ReadTimeout/WriteTimeout/PoolTimeout +_HTTP_NETWORK_ERRORS = () +try: + _HTTP_NETWORK_ERRORS = (httpx.TimeoutException, httpx.NetworkError) +except Exception: + _HTTP_NETWORK_ERRORS = (Exception,) + +_HTTP_STATUS_ERROR = httpx.HTTPStatusError if hasattr(httpx, "HTTPStatusError") else Exception + logger = logging.getLogger(__name__) @@ -67,6 +76,93 @@ def _resolve_video_model_id(model: str | None) -> str: return mid +# ── 视频错误分类(给前端/用户展示友好提示)──────────────────────────── + + +def _classify_video_error(status_code: int, body: str, err: Exception | None) -> tuple[str, str]: + """根据 HTTP 状态码和响应 body 判断错误类型。 + + 返回 (error_code, user_message): + - error_code: 机器可读的错误码("portrait_intercept" / "quota_exceeded" / "model_not_found" + / "invalid_param" / "auth_error" / "rate_limit" / "network_error" / "task_failed" / "unknown") + - user_message: 给用户看的中文提示 + """ + body_lower = (body or "").lower() + code_in_body = "" + msg_in_body = "" + try: + import json as _json + + parsed = _json.loads(body or "{}") + if isinstance(parsed, dict): + err_obj = parsed.get("error") or {} + if isinstance(err_obj, dict): + code_in_body = str(err_obj.get("code", "") or "") + msg_in_body = str(err_obj.get("message", "") or err_obj.get("msg", "") or "") + else: + msg_in_body = str(parsed.get("message", "") or "") + except Exception: + pass + + # 真人肖像/内容安全拦截 + if ( + status_code == 400 + and any( + kw in body_lower + for kw in ("portrait", "real_face", "human_face", "真人", "肖像", "人脸", "privacy", "real person", "face") + ) + ) or ( + "content" in body_lower + and ("risk" in body_lower or "block" in body_lower or "reject" in body_lower) + and status_code == 400 + ): + return ( + "portrait_intercept", + "参考素材包含真人照片被安全策略拦截,AI视频模型暂不支持上传真人照片作为参考图,请移除真人图片后重试。", + ) + + # 配额/计费问题 + if status_code in (402, 429) or any( + kw in body_lower for kw in ("quota", "billing", "insufficient", "欠费", "余额", "限流", "rate limit") + ): + if "rate" in body_lower or status_code == 429: + return "rate_limit", "视频生成服务当前繁忙(限流),请稍等1-2分钟后重试。" + return "quota_exceeded", "视频生成服务配额不足,请联系管理员充值或稍后重试。" + + # 模型/Endpoint 不存在 + if status_code == 404 or any( + kw in body_lower for kw in ("model not found", "endpoint not found", "不存在", "not found", "model_not_exist") + ): + return "model_not_found", f"视频模型未开通或模型ID无效({code_in_body or ''}),请联系管理员。" + + # 鉴权失败 + if status_code in (401, 403): + return "auth_error", "视频生成服务鉴权失败(API Key无效或过期),请联系管理员。" + + # 任务本身失败(轮询阶段拿到 status=failed) + if err and "task failed" in str(err).lower(): + detail = msg_in_body or str(err)[:200] + # 失败原因里再细分真人拦截 + if any(kw in detail.lower() for kw in ("portrait", "真人", "肖像", "人脸", "content_risk")): + return ( + "portrait_intercept", + "视频内容被安全策略拦截(疑似包含真人肖像),请更换参考图或调整文案后重试。", + ) + return "task_failed", f"视频生成失败:{detail}" + + # 参数错误 + if status_code == 400: + return "invalid_param", f"视频生成参数错误:{msg_in_body or body[:200]}" + + # 网络/连接问题 + if status_code == 0: + return "network_error", "视频生成服务连接失败(网络超时),请稍后重试。" + + # 默认 + detail = msg_in_body or (str(err) if err else "") or body[:200] + return "unknown", f"视频生成失败(HTTP {status_code}):{detail}" + + class DoubaoClient: """豆包大模型 API 客户端. @@ -84,6 +180,8 @@ class DoubaoClient: self.vision_model: str = settings.doubao_vision_model self.vision_lite_model: str = settings.doubao_vision_lite_model self.fast_model: str = settings.doubao_fast_model + # 最近一次视频生成的详细错误(error_code + user_message + raw detail),供上层读取后展示给用户 + self.last_video_error: dict = {} def embed_text(self, text: str, timeout: int | None = None) -> list[float] | None: """调用豆包文本 Embedding API,返回浮点向量;失败返回 None。""" @@ -307,7 +405,8 @@ class DoubaoClient: """调用 Seedance 2.5 生视频(异步任务→轮询→下载)。 成功返回 {"video_path": str, "usage": dict | None},失败返回 None。 - usage 是 Seedance 返回的计费信息(含 completion_tokens)。 + 失败时把详细错误信息(HTTP状态码、响应 body、分类后的用户提示)写入 self.last_video_error, + 上层可通过 get_last_video_error() 读取并展示给用户,不再笼统显示"返回为空"。 【v1.6.1 修复】严格按官方 content 数组协议构造请求: - 所有参考(图/音/视)必须放进 content 数组并带 role 字段,不能放顶层 reference_audios/reference_videos(非官方字段,会被忽略或导致异常)。 @@ -315,9 +414,24 @@ class DoubaoClient: 判定:传了参考音频/视频或 ≥1 张多参考图时,走 omni_reference(首张图 role=reference_image);纯首帧无参考时走 first_frame(ratio 强制 adaptive)。 - 创建任务若因 ratio 报错(HTTP 400),自动回退到 ratio=adaptive 重试一次。 """ + # 每次调用前清空上次错误 + self.last_video_error = {} + if not self.is_available: + self.last_video_error = { + "error_code": "auth_error", + "user_message": "视频生成服务未配置(API Key 缺失),请联系管理员。", + "status_code": 0, + "detail": "DoubaoClient not available (api_key empty)", + } return None if not prompt or not prompt.strip(): + self.last_video_error = { + "error_code": "invalid_param", + "user_message": "视频生成提示词不能为空。", + "status_code": 0, + "detail": "empty prompt", + } return None settings = get_shared_settings() @@ -331,10 +445,20 @@ class DoubaoClient: ds = get_dashscope_client() if ds is None: - logger.error("DashScope client 不可用(未配置 DASHSCOPE_API_KEY),video_model=%s", model) + err_msg = "DashScope client 不可用(未配置 DASHSCOPE_API_KEY)" + logger.error("%s, video_model=%s", err_msg, model) + self.last_video_error = { + "error_code": "auth_error", + "user_message": "Wan 3.0 视频模型未配置 API Key,请联系管理员。", + "status_code": 0, + "detail": err_msg, + } return None try: - return ds.video_generation( + # DashScope 客户端也设置 last_video_error 语义(如果它支持) + if hasattr(ds, "last_video_error"): + ds.last_video_error = {} + result = ds.video_generation( prompt=prompt, image_url=image_url, duration=duration, @@ -343,8 +467,17 @@ class DoubaoClient: output_dir=output_dir, model=video_model, ) + if not result and hasattr(ds, "last_video_error") and ds.last_video_error: + self.last_video_error = dict(ds.last_video_error) + return result except Exception as de: logger.error("DashScope video_generation 异常: %s", de, exc_info=True) + self.last_video_error = { + "error_code": "unknown", + "user_message": f"Wan 3.0 视频生成异常:{de!s}"[:200], + "status_code": 0, + "detail": str(de), + } return None ref_audios = [u for u in (reference_audios or [])[:10] if u and isinstance(u, str)] @@ -370,7 +503,7 @@ class DoubaoClient: } ) else: - # 纯首帧:不带 role,服务端识别为 first_frame(或显式 role=first_frame) + # 纯首帧:显式 role=first_frame content.append( { "type": "image_url", @@ -411,22 +544,38 @@ class DoubaoClient: len(ref_audios), len(ref_videos), ) + # 打印完整 payload 便于排查(截断 prompt) + debug_payload = dict(create_payload) + if "content" in debug_payload: + dbg_content = [] + for item in debug_payload["content"]: + item_copy = dict(item) + if item_copy.get("type") == "text" and isinstance(item_copy.get("text"), str): + item_copy["text"] = item_copy["text"][:200] + ("..." if len(item_copy["text"]) > 200 else "") + dbg_content.append(item_copy) + debug_payload["content"] = dbg_content + logger.info("Seedance 创建任务 payload: %s", json_safe_dumps(debug_payload)) def _do_create(payload: dict) -> tuple[str | None, Exception | None, int, str]: """返回 (task_id, last_err, status_code, body_text)。""" last_err: Exception | None = None + last_sc = 0 + last_body = "" for attempt in range(self.max_retries + 1): try: resp = httpx.post(create_url, headers=headers, json=payload, timeout=self.timeout) sc = int(getattr(resp, "status_code", 0) or 0) - body = (getattr(resp, "text", "") or "")[:1500] + body = (getattr(resp, "text", "") or "")[:2000] + last_sc = sc + last_body = body if sc >= 400: logger.error("Seedance 创建任务 HTTP %d: body=%s", sc, body) try: resp.raise_for_status() except Exception as ee: last_err = ee - if attempt < self.max_retries: + if attempt < self.max_retries and sc >= 500: + # 仅 5xx 重试,4xx 不重试(参数/鉴权/配额错误重试无意义) time.sleep(0.5 * (2**attempt)) continue return None, last_err, sc, body @@ -435,9 +584,19 @@ class DoubaoClient: if tid: return tid, None, sc, body last_err = RuntimeError(f"create ok but no id: {str(data)[:300]}") + except _HTTP_NETWORK_ERRORS as ne: + last_err = ne + last_sc = 0 + last_body = f"network error: {ne}" + logger.warning( + "Seedance 创建网络异常(%s),重试 %d/%d", type(ne).__name__, attempt + 1, self.max_retries + 1 + ) + if attempt < self.max_retries: + time.sleep(0.5 * (2**attempt)) + continue except Exception as e: last_err = e - if attempt < self.max_retries: + if attempt < self.max_retries and not isinstance(e, _HTTP_STATUS_ERROR): wait = 0.5 * (2**attempt) logger.warning( "Seedance 创建任务失败,%.1fs 后重试 (%d/%d): %s", @@ -447,7 +606,7 @@ class DoubaoClient: e, ) time.sleep(wait) - return None, last_err, 0, "" + return None, last_err, last_sc, last_body # 第一次尝试 task_id, last_err, sc, body = _do_create(create_payload) @@ -466,17 +625,30 @@ class DoubaoClient: logger.warning("Seedance 创建因 ratio 失败,回退 ratio=adaptive 重试") create_payload["ratio"] = "adaptive" task_id, last_err, sc2, body2 = _do_create(create_payload) + if task_id: + sc, body = sc2, body2 + else: + # 保留第二次的错误信息 + sc, body = sc2, body2 if not task_id: + err_code, user_msg = _classify_video_error(sc, body, last_err) + self.last_video_error = { + "error_code": err_code, + "user_message": user_msg, + "status_code": sc, + "detail": (body or "")[:500] or (str(last_err) if last_err else ""), + "model": video_model, + "base_url": self.base_url, + } logger.error( - "Seedance 创建任务最终失败: model=%s base_url=%s err=%s body=%s 【排查】" - "1) 方舟控制台已开通 %s;2) API Key 有该模型权限;" - "3) DOUBAO_BASE_URL=https://ark.cn-beijing.volces.com/api/v3;4) 参考素材 URL 公网可访问。", + "Seedance 创建任务最终失败: model=%s base_url=%s status=%d code=%s err=%s body=%s", video_model, self.base_url, + sc, + err_code, last_err, (body or "")[:500], - video_model, ) return None @@ -489,15 +661,21 @@ class DoubaoClient: usage: dict | None = None last_status: str = "queued" poll_count = 0 + last_poll_body: str = "" + last_poll_sc: int = 0 while time.time() < deadline: poll_count += 1 try: resp = httpx.get(poll_url, headers=headers, timeout=self.timeout) - try: - if int(getattr(resp, "status_code", 200)) >= 400: - resp.raise_for_status() - except (TypeError, ValueError): - pass + last_poll_sc = int(getattr(resp, "status_code", 200) or 200) + last_poll_body = (getattr(resp, "text", "") or "")[:1500] + if last_poll_sc >= 400: + logger.warning("Seedance 轮询 HTTP %d: %s", last_poll_sc, last_poll_body[:300]) + if poll_count < 3: + time.sleep(poll_interval) + continue + last_err = RuntimeError(f"poll HTTP {last_poll_sc}: {last_poll_body[:200]}") + break data = resp.json() status = data.get("status", "") last_status = status @@ -508,13 +686,22 @@ class DoubaoClient: if video_url: logger.info("Seedance 任务成功: task_id=%s polls=%d usage=%s", task_id, poll_count, usage) break - last_err = RuntimeError(f"task succeeded but no video_url: {str(data)[:300]}") - logger.error("Seedance succeeded 但无 video_url: %s", last_err) + # 成功但没 video_url:记录完整响应便于排查 + logger.error( + "Seedance succeeded 但无 video_url: task_id=%s full_response=%s", + task_id, + str(data)[:1000], + ) + last_err = RuntimeError("task succeeded but no video_url in response") + last_poll_body = str(data)[:1000] break if status == "failed": err = data.get("error") or {} - last_err = RuntimeError(f"task failed: code={err.get('code','')} msg={err.get('message','')}") - logger.error("Seedance 任务失败 task_id=%s: %s", task_id, last_err) + err_code = str(err.get("code", "") or "") + err_msg = str(err.get("message", "") or err.get("msg", "") or "") + last_err = RuntimeError(f"task failed: code={err_code} msg={err_msg}") + logger.error("Seedance 任务失败 task_id=%s code=%s msg=%s", task_id, err_code, err_msg) + last_poll_body = str(data)[:1000] break if status in ("expired", "cancelled"): last_err = RuntimeError(f"task {status}") @@ -525,18 +712,39 @@ class DoubaoClient: logger.info("Seedance 轮询中: task_id=%s status=%s polls=%d", task_id, status, poll_count) except httpx.HTTPStatusError as e: last_err = e - logger.warning("Seedance 轮询 HTTP %d: %s", e.response.status_code, (e.response.text or "")[:300]) + last_poll_sc = e.response.status_code + last_poll_body = (e.response.text or "")[:500] + logger.warning("Seedance 轮询 HTTP %d: %s", e.response.status_code, last_poll_body[:300]) except Exception as e: last_err = e logger.debug("Seedance 轮询异常: %s", e) time.sleep(poll_interval) if not video_url: + # 区分轮询超时 vs 任务失败 + if last_status in ("queued", "running", "pending") and poll_count > 0 and time.time() >= deadline: + err_code, user_msg = ( + "network_error", + f"视频生成超时(>{total_timeout}s),任务仍在排队,请稍后重试或联系管理员。", + ) + detail = f"timeout after {total_timeout}s, polls={poll_count}, last_status={last_status}" + else: + err_code, user_msg = _classify_video_error(last_poll_sc, last_poll_body, last_err) + detail = (last_poll_body or "")[:500] or (str(last_err) if last_err else f"last_status={last_status}") + self.last_video_error = { + "error_code": err_code, + "user_message": user_msg, + "status_code": last_poll_sc, + "detail": detail, + "task_id": task_id, + "last_status": last_status, + } logger.error( - "Seedance 任务未成功: task_id=%s last_status=%s polls=%d err=%s (总等待 %.0fs)", + "Seedance 任务未成功: task_id=%s last_status=%s polls=%d code=%s err=%s (总等待 %.0fs)", task_id, last_status, poll_count, + err_code, last_err, total_timeout, ) @@ -567,12 +775,41 @@ class DoubaoClient: os.remove(local_path) except Exception: pass + self.last_video_error = { + "error_code": "unknown", + "user_message": "视频生成成功但下载文件为空,请稍后重试。", + "status_code": 0, + "detail": f"downloaded 0 bytes from {video_url[:120]}", + } return None return {"video_path": local_path, "usage": usage} except Exception as e: logger.error("Seedance 视频下载失败: %s", e, exc_info=True) + self.last_video_error = { + "error_code": "network_error", + "user_message": f"视频下载失败:{e!s}"[:200], + "status_code": 0, + "detail": str(e), + } return None + def get_last_video_error(self) -> dict: + """返回最近一次 video_generation 失败的详细错误。空 dict 表示上次成功或未调用。""" + return dict(self.last_video_error or {}) + + +def json_safe_dumps(obj: Any, max_len: int = 2000) -> str: + """安全 json 序列化,失败则 fallback 到 repr,超长截断。""" + try: + import json as _json + + s = _json.dumps(obj, ensure_ascii=False, default=str) + except Exception: + s = repr(obj) + if len(s) > max_len: + s = s[:max_len] + f"...(truncated, total {len(s)})" + return s + # ── 单例 ───────────────────────────────────────────────────────────────────── diff --git a/packages/shared/ai_service.py b/packages/shared/ai_service.py index 9881f175b..0bd00e5f3 100755 --- a/packages/shared/ai_service.py +++ b/packages/shared/ai_service.py @@ -618,20 +618,23 @@ def call_video_generation( reference_audios: list[str] | None = None, reference_videos: list[str] | None = None, ) -> dict | None: - """调用 Seedance 2.5 生成视频(v1.6.1 单次出片版)。 + """调用 Seedance / Wan 视频生成(v1.6.2 多模型版)。 成功返回 {"video_path": str, "usage": dict | None}(usage 含 completion_tokens),失败返回 None。 - - v1.6.1 关键约束(避免 20min 卡死): - - 参考音频/视频/多图全部放进 content 数组并带 role=reference_audio/reference_video/reference_image; - - 纯首帧无参考时(first_frame 模式),Seedance 2.5 强制 ratio=adaptive; - 传了参考音/视/多图时走 omni_reference 模式,ratio 可指定为 9:16(客户端内部自动判断)。 - - ratio 默认 9:16(竖屏),客户端会根据是否有参考自动在 first_frame/adaptive 与 omni/9:16 间切换; - 若创建任务因 ratio 报错(HTTP 400),客户端会自动回退到 adaptive 再试一次。 + 失败时错误详情会写入 client.last_video_error,可通过 get_last_video_error() 读取: + {"error_code": str, "user_message": str, "status_code": int, "detail": str, ...} """ client = get_doubao_client() if not client.is_available: - logger.warning("[ai_service] 豆包客户端未配置,跳过视频生成") + msg = "豆包客户端未配置(DOUBAO_API_KEY 缺失),跳过视频生成" + logger.warning("[ai_service] %s", msg) + # 写入 last_video_error 供上层读取 + client.last_video_error = { + "error_code": "auth_error", + "user_message": "视频生成服务未配置,请联系管理员。", + "status_code": 0, + "detail": msg, + } return None effective_ratio = ratio or "9:16" try: @@ -653,4 +656,21 @@ def call_video_generation( return client.video_generation(**kwargs) except Exception as e: logger.error("[ai_service] call_video_generation 异常: %s", e, exc_info=True) + client.last_video_error = { + "error_code": "unknown", + "user_message": f"视频生成异常:{e!s}"[:200], + "status_code": 0, + "detail": str(e), + } return None + + +def get_last_video_error() -> dict: + """读取最近一次视频生成失败的详细错误(含 error_code/user_message/status_code/detail)。 + 成功或未调用过返回空 dict。 + """ + try: + client = get_doubao_client() + return client.get_last_video_error() if hasattr(client, "get_last_video_error") else {} + except Exception: + return {} diff --git a/packages/shared/dashscope_client.py b/packages/shared/dashscope_client.py index e5e043ac8..3236791b2 100644 --- a/packages/shared/dashscope_client.py +++ b/packages/shared/dashscope_client.py @@ -21,11 +21,59 @@ import httpx from packages.shared.config import get_shared_settings +# 网络/超时类异常父类集合:覆盖 Timeout/Connect/Network/ReadTimeout/WriteTimeout/PoolTimeout +_HTTP_NETWORK_ERRORS = () +try: + _HTTP_NETWORK_ERRORS = (httpx.TimeoutException, httpx.NetworkError) +except Exception: + _HTTP_NETWORK_ERRORS = (Exception,) + +_HTTP_STATUS_ERROR = httpx.HTTPStatusError if hasattr(httpx, "HTTPStatusError") else Exception + logger = logging.getLogger(__name__) _DASHSCOPE_CLIENT_SINGLETON: "DashScopeClient | None" = None +def _classify_dashscope_error(status_code: int, body: str, task_msg: str = "") -> tuple[str, str]: + """DashScope 错误分类,返回 (error_code, user_message)。""" + body_lower = (body or "").lower() + msg_in_body = task_msg or "" + try: + import json as _json + + parsed = _json.loads(body or "{}") + if isinstance(parsed, dict): + msg_in_body = msg_in_body or str(parsed.get("message", "") or "") + except Exception: + pass + + if status_code in (401, 403): + return "auth_error", "Wan 3.0 服务鉴权失败(DASHSCOPE_API_KEY 无效或过期),请联系管理员。" + if status_code == 429 or "rate" in body_lower or "throttl" in body_lower: + return "rate_limit", "Wan 3.0 服务繁忙(限流),请稍等1-2分钟后重试。" + if status_code == 400 and any( + kw in body_lower for kw in ("portrait", "真人", "人脸", "肖像", "content_violation", "risk", "blocked") + ): + return ( + "portrait_intercept", + "参考素材包含真人照片或违规内容被安全策略拦截,请移除真人图片或调整文案后重试。", + ) + if status_code == 404 or ("not found" in body_lower) or ("model" in body_lower and "not exist" in body_lower): + return "model_not_found", "Wan 3.0 模型未开通或模型ID无效,请联系管理员。" + if status_code in (402, 400) and ("quota" in body_lower or "billing" in body_lower or "insufficient" in body_lower): + return "quota_exceeded", "Wan 3.0 服务配额不足,请联系管理员充值或稍后重试。" + if status_code == 400: + return "invalid_param", f"Wan 3.0 参数错误:{msg_in_body or body[:200]}" + if status_code == 0: + return "network_error", "Wan 3.0 服务连接失败(网络超时),请稍后重试。" + # 任务内失败 + if task_msg and any(kw in task_msg.lower() for kw in ("portrait", "真人", "人脸", "violation", "blocked")): + return "portrait_intercept", "Wan 3.0 视频内容被安全策略拦截,请调整文案或参考图后重试。" + detail = msg_in_body or body[:200] + return "unknown", f"Wan 3.0 视频生成失败(HTTP {status_code}):{detail}" + + class DashScopeClient: """阿里云 DashScope 异步 API 客户端(Wan 3.0 等视频生成)。""" @@ -38,11 +86,24 @@ class DashScopeClient: self.poll_interval: int = int(getattr(settings, "dashscope_video_poll_interval", 10) or 10) self.total_timeout: int = int(getattr(settings, "dashscope_video_timeout", 900) or 900) self.max_retries: int = 2 + self.last_video_error: dict = {} @property def is_available(self) -> bool: return bool(self.api_key) + def get_last_video_error(self) -> dict: + return dict(self.last_video_error or {}) + + def _set_error(self, error_code: str, user_message: str, status_code: int = 0, detail: str = "", **extra) -> None: + self.last_video_error = { + "error_code": error_code, + "user_message": user_message, + "status_code": status_code, + "detail": detail[:500] if detail else "", + **extra, + } + def video_generation( self, prompt: str, @@ -57,12 +118,15 @@ class DashScopeClient: ) -> dict | None: """调用 DashScope 异步视频合成接口,轮询完成后下载到本地。 - 返回 {"video_path": str, "usage": dict | None};失败返回 None。 + 返回 {"video_path": str, "usage": dict | None};失败返回 None,错误详情写入 self.last_video_error。 """ + self.last_video_error = {} if not self.is_available: + self._set_error("auth_error", "Wan 3.0 API key 未配置,请联系管理员。", detail="dashscope api_key empty") logger.error("[dashscope] API key 未配置,无法调用视频生成") return None if not prompt or not prompt.strip(): + self._set_error("invalid_param", "视频生成提示词不能为空。", detail="empty prompt") return None # DashScope 分辨率参数:720P / 1080P / 480P(大写 P) @@ -106,18 +170,27 @@ class DashScopeClient: ds_res, bool(image_url), ) + logger.info("[dashscope] 创建任务 payload: model=%s params=%s", model, params) # 创建任务 task_id: str | None = None - last_err: Exception | None = None + last_sc = 0 + last_body = "" for attempt in range(self.max_retries + 1): try: resp = httpx.post(create_url, headers=headers, json=payload, timeout=60) sc = int(getattr(resp, "status_code", 0) or 0) - body_text = (getattr(resp, "text", "") or "")[:1500] + body_text = (getattr(resp, "text", "") or "")[:2000] + last_sc = sc + last_body = body_text if sc >= 400: logger.error("[dashscope] 创建任务 HTTP %d: %s", sc, body_text) - resp.raise_for_status() + if sc >= 500 and attempt < self.max_retries: + time.sleep(0.5 * (2**attempt)) + continue + err_code, user_msg = _classify_dashscope_error(sc, body_text) + self._set_error(err_code, user_msg, sc, body_text, model=model) + return None data = resp.json() tid = (data.get("output") or {}).get("task_id") if tid: @@ -126,17 +199,34 @@ class DashScopeClient: # 部分情况下 code != 错误 code = data.get("code") if code and code != "": - last_err = RuntimeError(f"dashscope create failed: {body_text[:300]}") + err_code, user_msg = _classify_dashscope_error(400, body_text, str(code)) + self._set_error(err_code, user_msg, sc, body_text, model=model) + return None else: - last_err = RuntimeError(f"create ok but no task_id: {str(data)[:300]}") - except Exception as e: - last_err = e + self._set_error("unknown", "Wan 3.0 响应格式异常,未返回任务ID", sc, str(data)[:500], model=model) + return None + except _HTTP_NETWORK_ERRORS as ne: + last_sc = 0 + last_body = f"network error: {ne}" + logger.warning( + "[dashscope] 网络异常 %s,重试 %d/%d", type(ne).__name__, attempt + 1, self.max_retries + 1 + ) if attempt < self.max_retries: time.sleep(0.5 * (2**attempt)) continue - logger.error("[dashscope] 创建任务最终失败: %s", last_err) + self._set_error("network_error", "Wan 3.0 服务连接失败(网络超时),请稍后重试。", 0, str(ne)) + return None + except Exception as _e: + if attempt < self.max_retries: + time.sleep(0.5 * (2**attempt)) + continue + logger.error("[dashscope] 创建任务最终失败: %s", _e) + self._set_error("unknown", f"Wan 3.0 创建任务异常:{_e!s}"[:200], 0, str(_e)) return None if not task_id: + if not self.last_video_error: + err_code, user_msg = _classify_dashscope_error(last_sc, last_body) + self._set_error(err_code, user_msg, last_sc, last_body, model=model) return None # 轮询任务 @@ -144,16 +234,26 @@ class DashScopeClient: deadline = time.time() + self.total_timeout video_url: str | None = None usage: dict | None = None + poll_count = 0 + last_status = "" while time.time() < deadline: + poll_count += 1 try: r = httpx.get(poll_url, headers=headers, timeout=30) - if int(getattr(r, "status_code", 0) or 0) >= 400: - logger.warning("[dashscope] 轮询 HTTP %d", r.status_code) - time.sleep(self.poll_interval) - continue + psc = int(getattr(r, "status_code", 0) or 0) + pbody = (getattr(r, "text", "") or "")[:1500] + if psc >= 400: + logger.warning("[dashscope] 轮询 HTTP %d: %s", psc, pbody[:300]) + if poll_count < 3: + time.sleep(self.poll_interval) + continue + err_code, user_msg = _classify_dashscope_error(psc, pbody) + self._set_error(err_code, user_msg, psc, pbody, task_id=task_id) + return None d = r.json() out = d.get("output") or {} task_status = out.get("task_status") or d.get("task_status") or "" + last_status = task_status if task_status == "SUCCEEDED": video_url = out.get("video_url") or "" usage = d.get("usage") @@ -165,22 +265,41 @@ class DashScopeClient: if video_url: logger.info("[dashscope] 任务 %s 完成: %s", task_id, video_url[:120]) break - logger.error("[dashscope] 任务 %s SUCCEEDED 但无 video_url", task_id) + logger.error("[dashscope] 任务 %s SUCCEEDED 但无 video_url: %s", task_id, str(d)[:500]) + self._set_error( + "unknown", + "Wan 3.0 任务成功但未返回视频URL,请联系管理员。", + 200, + str(d)[:500], + task_id=task_id, + ) return None if task_status in ("FAILED", "FAILED_WITH_ERROR", "ERROR"): - msg = out.get("message") or d.get("message") or "unknown error" + msg = out.get("message") or d.get("message") or out.get("error_msg") or "unknown error" logger.error("[dashscope] 任务 %s 失败: %s", task_id, msg) + err_code, user_msg = _classify_dashscope_error(200, "", msg) + self._set_error(err_code, user_msg, 200, msg, task_id=task_id, last_status=task_status) return None if task_status in ("CANCELED", "CANCELLED"): logger.warning("[dashscope] 任务 %s 被取消", task_id) + self._set_error("unknown", "Wan 3.0 任务被取消。", 200, "task cancelled", task_id=task_id) return None # PENDING / RUNNING / SUSPENDED → 继续轮询 - logger.debug("[dashscope] 任务 %s 状态 %s,继续轮询", task_id, task_status) + if poll_count % 5 == 0: + logger.info("[dashscope] 轮询中 task=%s status=%s polls=%d", task_id, task_status, poll_count) except Exception as e: logger.warning("[dashscope] 轮询异常: %s", e) time.sleep(self.poll_interval) if not video_url: logger.error("[dashscope] 任务 %s 轮询超时(%ds)", task_id, self.total_timeout) + self._set_error( + "network_error", + f"Wan 3.0 视频生成超时(>{self.total_timeout}s),任务仍在排队,请稍后重试。", + 0, + f"timeout after {self.total_timeout}s, polls={poll_count}, last_status={last_status}", + task_id=task_id, + last_status=last_status, + ) return None # 下载视频 @@ -193,19 +312,23 @@ class DashScopeClient: out_path = os.path.join(out_dir, f"wan_{safe_tid}{suffix}") try: with httpx.stream("GET", video_url, timeout=300, follow_redirects=True) as resp: - if int(getattr(resp, "status_code", 0) or 0) >= 400: - logger.error("[dashscope] 下载 HTTP %d", resp.status_code) + dsc = int(getattr(resp, "status_code", 0) or 0) + if dsc >= 400: + logger.error("[dashscope] 下载 HTTP %d", dsc) + self._set_error("network_error", "Wan 3.0 视频下载失败(HTTP错误),请稍后重试。", dsc) return None with open(out_path, "wb") as f: for chunk in resp.iter_bytes(chunk_size=1024 * 256): if chunk: f.write(chunk) except Exception as e: - logger.error("[dashscope] 下载视频失败: %s", e) + logger.error("[dashscope] 下载视频失败: %s", e, exc_info=True) + self._set_error("network_error", f"Wan 3.0 视频下载失败:{e!s}"[:200], 0, str(e)) return None size = os.path.getsize(out_path) if os.path.exists(out_path) else 0 if size < 1024: logger.error("[dashscope] 下载文件过小: %d bytes", size) + self._set_error("unknown", "Wan 3.0 视频下载文件过小,请稍后重试。", 0, f"downloaded only {size} bytes") return None logger.info("[dashscope] 视频已下载: %s (%d bytes)", out_path, size) return {"video_path": out_path, "usage": usage} diff --git a/tests/unit/test_ai_client_video.py b/tests/unit/test_ai_client_video.py index f53f42620..26438957f 100644 --- a/tests/unit/test_ai_client_video.py +++ b/tests/unit/test_ai_client_video.py @@ -556,3 +556,103 @@ class TestResolveVideoModelId: # 未知 model key 会通过 get_viral_video_model_config 回落到 seedance-2.5 with caplog.at_level(logging.WARNING, logger="shared.ai_client"): assert fn("some-random-model") == "doubao-seedance-2-5-260628" + + +# ── #2165 详细错误信息和 last_video_error ───────────────────────── + + +class TestVideoGenerationLastError: + def test_create_400_portrait_returns_user_message(self, tmp_path): + """HTTP 400 + 真人拦截关键词 → portrait_intercept 错误码,用户提示友好。""" + client = _make_client(max_retries=0) + create_resp = MagicMock() + create_resp.status_code = 400 + create_resp.text = '{"error":{"code":"ContentRisk","message":"Real person face detected in reference image, portrait blocked"}}' + create_resp.json.return_value = {"error": {"code": "ContentRisk", "message": "..."}} + create_resp.raise_for_status.side_effect = httpx.HTTPStatusError( + "bad", request=MagicMock(), response=create_resp + ) + with ( + patch("packages.shared.ai_client.httpx.post", return_value=create_resp), + patch("packages.shared.ai_client.time.sleep", return_value=None), + patch("packages.shared.ai_client.get_shared_settings") as mock_s, + ): + mock_s.return_value = MagicMock( + doubao_video_poll_interval=0, doubao_video_timeout=1, doubao_video_model="seedance" + ) + result = client.video_generation("p", output_dir=str(tmp_path), image_url="https://img/x.jpg") + assert result is None + err = client.get_last_video_error() + assert err["error_code"] == "portrait_intercept" + assert "真人" in err["user_message"] + assert err["status_code"] == 400 + + def test_create_401_returns_auth_error(self, tmp_path): + client = _make_client(max_retries=0) + create_resp = MagicMock() + create_resp.status_code = 401 + create_resp.text = '{"error":{"message":"Unauthorized"}}' + create_resp.json.return_value = {"error": {"message": "Unauthorized"}} + create_resp.raise_for_status.side_effect = httpx.HTTPStatusError( + "auth", request=MagicMock(), response=create_resp + ) + with ( + patch("packages.shared.ai_client.httpx.post", return_value=create_resp), + patch("packages.shared.ai_client.time.sleep", return_value=None), + patch("packages.shared.ai_client.get_shared_settings") as mock_s, + ): + mock_s.return_value = MagicMock( + doubao_video_poll_interval=0, doubao_video_timeout=1, doubao_video_model="seedance" + ) + result = client.video_generation("p", output_dir=str(tmp_path)) + assert result is None + err = client.get_last_video_error() + assert err["error_code"] == "auth_error" + assert err["status_code"] == 401 + + def test_poll_failed_returns_task_failed_error(self, tmp_path): + """轮询 status=failed 时应记录 task_failed 错误并含 detail。""" + client = _make_client(max_retries=0) + create_resp = MagicMock() + create_resp.status_code = 200 + create_resp.json.return_value = {"id": "t-fail"} + create_resp.raise_for_status = MagicMock() + poll_resp = MagicMock() + poll_resp.status_code = 200 + poll_resp.json.return_value = { + "status": "failed", + "error": {"code": "InvalidParam", "message": "resolution invalid"}, + } + poll_resp.raise_for_status = MagicMock() + with ( + patch("packages.shared.ai_client.httpx.post", return_value=create_resp), + patch("packages.shared.ai_client.httpx.get", return_value=poll_resp), + patch("packages.shared.ai_client.time.sleep", return_value=None), + patch("packages.shared.ai_client.time.time", side_effect=_fake_time_factory()), + patch("packages.shared.ai_client.get_shared_settings") as mock_s, + ): + mock_s.return_value = MagicMock( + doubao_video_poll_interval=0, doubao_video_timeout=10, doubao_video_model="seedance" + ) + result = client.video_generation("p", output_dir=str(tmp_path)) + assert result is None + err = client.get_last_video_error() + assert err["error_code"] == "task_failed" + assert "InvalidParam" in err.get("detail", "") or err["status_code"] == 200 + + +class TestAiServiceLastVideoError: + def test_call_video_generation_returns_none_sets_error(self): + """失败后 get_last_video_error 应返回结构化错误信息。""" + from packages.shared import ai_service + + mock_client = MagicMock() + mock_client.is_available = True + mock_client.last_video_error = {"error_code": "unknown", "user_message": "test"} + mock_client.get_last_video_error.return_value = {"error_code": "unknown", "user_message": "test"} + mock_client.video_generation.return_value = None + with patch("packages.shared.ai_service.get_doubao_client", return_value=mock_client): + assert ai_service.call_video_generation("p") is None + err = ai_service.get_last_video_error() + assert err["error_code"] == "unknown" + assert "user_message" in err diff --git a/tests/unit/test_dashscope_client.py b/tests/unit/test_dashscope_client.py index 82c751a4f..4691f2634 100644 --- a/tests/unit/test_dashscope_client.py +++ b/tests/unit/test_dashscope_client.py @@ -4,6 +4,7 @@ from __future__ import annotations from unittest.mock import MagicMock, mock_open, patch +import httpx import pytest _SINGLETON = "_DASHSCOPE_CLIENT_SINGLETON" @@ -157,3 +158,21 @@ class TestDashScopeVideoGeneration: c.video_generation(prompt=" ", duration=5, ratio="9:16", resolution="720p", output_dir="/tmp/videos") is None ) + + def test_create_400_sets_last_video_error(self, tmp_path): + """创建任务 HTTP 400 时应写 last_video_error。""" + from packages.shared import dashscope_client as dc + + dc._DASHSCOPE_CLIENT_SINGLETON = None + with patch.dict("os.environ", {"DASHSCOPE_API_KEY": "test-key"}): + c = dc.DashScopeClient() + r = MagicMock() + r.status_code = 401 + r.text = '{"code":"InvalidApiKey","message":"bad key"}' + r.raise_for_status.side_effect = httpx.HTTPStatusError("auth", request=MagicMock(), response=r) + with patch.object(dc.httpx, "post", return_value=r), patch.object(dc, "time"): + out = c.video_generation("p", output_dir=str(tmp_path)) + assert out is None + err = c.get_last_video_error() + assert err["error_code"] == "auth_error" + assert c.last_video_error is not None