fix: 修复资产/视频/三视图三处 bug + 脚本模型切 YunQi gemini

1. 视频「全部生成」串行短路:任一段提交失败就 break 致后面段不提交
   (4 个框只跑 2 个)→ 改 Promise.allSettled 并发,单段失败不拖累其余,
   部分失败也刷新并如实报告。(App.tsx)
2. 基础资产/三视图 AI 出图偶发失败「生成不出来」:中转站瞬时抖动
   (网络/5xx/429/空返回)一次失败即退费 → 加退避重试 3 次,永久错误
   (4xx 内容违规)仍立即退费。(services.py run_base_asset_task/run_triview_task)
3. 商品三视图无法用已有素材:商品卡加「使用已有素材」,选商品已有图 /
   上传本地图直接 attach 成三视图(不走平台 AI 生成、不计费)。(pipeline.tsx)
4. 脚本助手:tokenssr 欠费致 GPT-5.5/Gemini 全挂 → 文本模型切 YunQi。
   实测 YunQi gpt-5.5 频道扛不住重型结构化任务(skill 全提示词仅 1/5 成功、
   常吐空无 JSON),gemini-3.1-pro 5/5 稳定 → 默认改 gemini,停用 gpt-5.5。
   按「一 provider 一 key」拆 yunqi_gpt / yunqi_gemini 两 provider。
   (.env / settings / 迁移 0017+0018)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-25 12:00:03 +08:00
co-authored by Claude Opus 4.8
parent 8222a46202
commit dea9dbb3ed
7 changed files with 274 additions and 20 deletions
+3
View File
@@ -26,6 +26,9 @@ VIDEO_ARK_API_KEY=9225161a-a640-47e6-94ed-81f6c5610072
DEFAULT_TRIAL_CREDITS=1000.0000
YUNQI_API_KEY=sk-xdP2iy5kzmehinLkI1lxV2BmpGSXma2wvKbSVP3tZBPHH6zf
YUNQI_BASE_URL=https://www.yunqiai.chat/v1
# YunQi 对话(脚本助手)· 按模型分开计费的两把 key(同一 base_url,各自独立额度)
YUNQI_GPT_API_KEY=sk-WX9ClkkjJmquaz4CsmtiUTNQ7fYgDmngNasDUUjbK504uFEd
YUNQI_GEMINI_API_KEY=sk-WNmbNpwHiln5PUfSFgepXKdfoIe3L59rSpaYffp1hHqZ0s5N
# tokenssr 中转站(主力:gpt-image-2 参考图 + gpt-5.5/gemini 文本 + gemini 图像)· 一把 key 通吃 90 模型
TOKENSSR_API_KEY=sk-vDg00IAX6EW4ABo4ePwAFPRaIjScFLUs1ZM1lInZJvs7z6M0
+6
View File
@@ -206,11 +206,17 @@ TOKENSSR = {
PROVIDER_BASE_URLS = {
"volcengine": env("VOLCANO_ARK_BASE_URL", "https://ark.cn-beijing.volces.com/api/v3"),
"yunqi": env("YUNQI_BASE_URL", "https://www.yunqiai.chat/v1"),
# YunQi 对话(脚本助手):gpt-5.5 与 gemini-3.1-pro-preview 在 YunQi 同一地址、各有独立计费 key,
# 故拆成两个 provider(一 provider 一 key 是现有约定)。base_url 都指 YunQi。
"yunqi_gpt": env("YUNQI_BASE_URL", "https://www.yunqiai.chat/v1"),
"yunqi_gemini": env("YUNQI_BASE_URL", "https://www.yunqiai.chat/v1"),
"tokenssr": env("TOKENSSR_BASE_URL", "https://king.tokenssr.com/v1"),
}
PROVIDER_KEYS = {
"volcengine": env("VOLCANO_ARK_API_KEY", ""),
"yunqi": env("YUNQI_API_KEY", ""),
"yunqi_gpt": env("YUNQI_GPT_API_KEY", ""),
"yunqi_gemini": env("YUNQI_GEMINI_API_KEY", ""),
"tokenssr": env("TOKENSSR_API_KEY", ""),
}
# 临时:视频(Seedance)单独借 AirDrama 火山账号的 ARK key。真人素材库审核(ASSETS_API)建在 AirDrama 账号下,
@@ -0,0 +1,87 @@
"""脚本助手文本模型从 tokenssr 切到 YunQi(tokenssr 欠费致 GPT-5.5/Gemini 全挂)。
YunQi 同一地址(https://www.yunqiai.chat/v1)对 gpt / gemini 分开计费、各一把 key,故按
「一 provider 一 key」拆成两个 provider:
- yunqi_gpt → gpt-5.5(默认脚本模型) · key=.env YUNQI_GPT_API_KEY
- yunqi_gemini → gemini-3.1-pro-preview · key=.env YUNQI_GEMINI_API_KEY
凭证留空 → 运行时由 settings.PROVIDER_KEYS 从 .env 解析(密钥不进库)。
模型名经实测确认:gpt-5.5 / gemini-3.1-pro-preview 在 YunQi 返回 200 且能流式出正文;
其余名(gpt-5 / gemini-3-pro-preview …)YunQi 报 model_not_found。
同时停用旧的 tokenssr 文本模型(gpt-5.5 / gemini-3-pro-preview),让 get_default_model(text)
选到新的 YunQi gpt-5.5。幂等:全部 update_or_create / 显式置位,可重复 apply。
"""
from django.db import migrations
YUNQI_BASE_URL = "https://www.yunqiai.chat/v1"
# (provider_name, display, model_name, model_display, is_default)
YUNQI_TEXT = [
("yunqi_gpt", "YunQi · GPT", "gpt-5.5", "GPT-5.5", True),
("yunqi_gemini", "YunQi · Gemini", "gemini-3.1-pro-preview", "Gemini 3.1 Pro", False),
]
def seed(apps, schema_editor):
ModelProvider = apps.get_model("ai", "ModelProvider")
ModelConfig = apps.get_model("ai", "ModelConfig")
for prov_name, prov_display, model_name, model_display, is_default in YUNQI_TEXT:
provider, _ = ModelProvider.objects.get_or_create(
name=prov_name,
defaults={
"display_name": prov_display,
"status": "active",
"base_url": YUNQI_BASE_URL,
"api_key": "", # 运行时从 .env(PROVIDER_KEYS)解析,密钥不进库
"metadata": {"kind": "relay", "gateway": "yunqi"},
},
)
# down→up 循环后 get_or_create 命中旧行(可能 disabled / 缺 base_url),显式补齐
changed = []
if provider.status != "active":
provider.status = "active"; changed.append("status")
if not provider.base_url:
provider.base_url = YUNQI_BASE_URL; changed.append("base_url")
if changed:
provider.save(update_fields=changed)
ModelConfig.objects.update_or_create(
provider=provider,
name=model_name,
capability="text",
defaults={
"display_name": model_display,
"endpoint": "chat/completions",
"unit_price": "1.0000",
"status": "active",
"is_default": is_default,
"metadata": {"gateway": "yunqi", "verified": True},
},
)
# 停用旧的 tokenssr 文本模型(欠费),并清掉它们可能带的 is_default,避免与新默认打架
ModelConfig.objects.filter(
provider__name="tokenssr", capability="text",
).update(status="disabled", is_default=False)
def unseed(apps, schema_editor):
ModelConfig = apps.get_model("ai", "ModelConfig")
# 回滚:停用 YunQi 文本模型,恢复 tokenssr 文本模型为 active(gpt-5.5 复位默认)
ModelConfig.objects.filter(provider__name__in=["yunqi_gpt", "yunqi_gemini"], capability="text").update(
status="disabled", is_default=False,
)
ModelConfig.objects.filter(provider__name="tokenssr", capability="text").update(status="active")
ModelConfig.objects.filter(provider__name="tokenssr", capability="text", name="gpt-5.5").update(is_default=True)
class Migration(migrations.Migration):
dependencies = [
("ai", "0016_person_triview_16_9"),
]
operations = [
migrations.RunPython(seed, unseed),
]
@@ -0,0 +1,38 @@
"""脚本默认模型从 gpt-5.5 改为 gemini-3.1-pro-preview,并停用 YunQi 的 gpt-5.5。
实测(完整电商脚本 skill ~28KB 重型结构化任务,各连发 5 次):
- YunQi gpt-5.5 → 仅 1/5 成功(3 次吐空、1 次出文无合法 JSON,成功那次还要 69-95s)→ 频道扛不住,不可用
- YunQi gemini-3.1-pro → 5/5 成功(每次 ~2300 字合法 JSON,30-48s 稳定)
故默认切 gemini;gpt-5.5 停用(YunQi 频道修好后改回 active 即可,一行)。
幂等:显式置位,可重复 apply。
"""
from django.db import migrations
def apply(apps, schema_editor):
ModelConfig = apps.get_model("ai", "ModelConfig")
# gemini 设默认 + 确保 active
ModelConfig.objects.filter(
provider__name="yunqi_gemini", capability="text", name="gemini-3.1-pro-preview",
).update(is_default=True, status="active")
# gpt-5.5 取消默认 + 停用(频道不可用)
ModelConfig.objects.filter(
provider__name="yunqi_gpt", capability="text", name="gpt-5.5",
).update(is_default=False, status="disabled")
def revert(apps, schema_editor):
ModelConfig = apps.get_model("ai", "ModelConfig")
ModelConfig.objects.filter(provider__name="yunqi_gemini", capability="text", name="gemini-3.1-pro-preview").update(is_default=False)
ModelConfig.objects.filter(provider__name="yunqi_gpt", capability="text", name="gpt-5.5").update(is_default=True, status="active")
class Migration(migrations.Migration):
dependencies = [
("ai", "0017_switch_script_to_yunqi"),
]
operations = [
migrations.RunPython(apply, revert),
]
+57 -14
View File
@@ -2,11 +2,14 @@ import json
import re
import subprocess
import tempfile
import time
import uuid
from datetime import timedelta
from decimal import Decimal
from io import BytesIO
from pathlib import Path
import requests
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.db import transaction
@@ -865,6 +868,38 @@ def build_person_frontal_prompt(description: str = "") -> str:
return render_prompt("person_portrait", default, 描述=desc)
# --------------------------------------------------------------------------- #
# 出图重试(中转站偶发抖动 → 一次失败就「生成不出来」的根因兜底)
# --------------------------------------------------------------------------- #
_IMAGE_GEN_ATTEMPTS = 3
def _is_transient_image_error(exc: Exception) -> bool:
"""判定出图失败是否「瞬时」(值得重试)。瞬时:网络抖动 / 5xx / 429 限流 / 中转站吐空无 media url。
永久(不重试,立即退费报错):4xx 内容违规 / 参数错(如 invalid_image_file)、配置缺失等。"""
if isinstance(exc, (requests.ConnectionError, requests.Timeout)):
return True
if isinstance(exc, requests.HTTPError):
code = getattr(getattr(exc, "response", None), "status_code", 0) or 0
return code >= 500 or code == 429
# extract_first_media_url 在响应没有 url 时抛 ValueError —— 多为中转站偶发空返回,重试常能拿到
if isinstance(exc, ValueError) and "media url" in str(exc).lower():
return True
return False
def _run_image_with_retry(make, *, attempts: int = _IMAGE_GEN_ATTEMPTS):
"""跑一次出图(provider 调用 + 取 media url),瞬时错误按退避重试,永久错误立即抛。
make() 须返回 (response, media)。退避 2s / 4s,总耗时上限 ~6s + 出图本身,worker 内执行对用户无感。"""
for i in range(attempts):
try:
return make()
except Exception as exc: # noqa: BLE001
if i == attempts - 1 or not _is_transient_image_error(exc):
raise
time.sleep(2 * (i + 1))
def generate_base_asset(*, project, user, kind: str, prompt: str, label: str = "", group_id: str | None = None) -> AITask:
"""提交基础资产生成(**异步**):Web 请求只建 RESERVED 任务 + 预留额度(秒级),
慢出图(文生图 / 商品 image_edit)交给 Celery worker(run_base_asset_task)跑。
@@ -927,18 +962,22 @@ def run_base_asset_task(*, task_id: str) -> None:
provider = get_image_provider(model_config)
reservation = task.credit_reservation
try:
if use_edit and ref_url:
# 商品三视图:比例可在 admin 改(默认横)
size = prompt_ratio_size("product_triview", "1536x1024")
response = provider.image_edit(model=model_config.name, prompt=prompt, images=[ref_url], size=size)
else:
# 场景默认横、人物立绘默认竖;两者比例都可在 admin 改
if kind == BaseAssetGroup.Kind.SCENE:
gen_size = prompt_ratio_size("scene", "1536x1024")
def _make():
if use_edit and ref_url:
# 商品三视图:比例可在 admin 改(默认横)
size = prompt_ratio_size("product_triview", "1536x1024")
resp = provider.image_edit(model=model_config.name, prompt=prompt, images=[ref_url], size=size)
else:
gen_size = prompt_ratio_size("person_portrait", "1024x1536")
response = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=prompt, size=gen_size)
media = provider.extract_first_media_url(response)
# 场景默认横、人物立绘默认竖;两者比例都可在 admin 改
if kind == BaseAssetGroup.Kind.SCENE:
gen_size = prompt_ratio_size("scene", "1536x1024")
else:
gen_size = prompt_ratio_size("person_portrait", "1024x1536")
resp = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=prompt, size=gen_size)
return resp, provider.extract_first_media_url(resp)
# 中转站偶发抖动(网络/5xx/限流/空返回)按退避重试,避免「一次失败就生成不出来」;永久错误仍立即退费
response, media = _run_image_with_retry(_make)
with transaction.atomic():
task.status = AITask.Status.SUCCEEDED
task.response_payload = response
@@ -1027,9 +1066,13 @@ def run_triview_task(*, task_id: str) -> None:
provider = get_image_provider(model_config)
reservation = task.credit_reservation
try:
tri_size = prompt_ratio_size("person_triview", "1536x1024")
response = provider.image_edit(model=model_config.name, prompt=prompt, images=[ref_url], size=tri_size)
media = provider.extract_first_media_url(response)
def _make():
tri_size = prompt_ratio_size("person_triview", "1536x1024")
resp = provider.image_edit(model=model_config.name, prompt=prompt, images=[ref_url], size=tri_size)
return resp, provider.extract_first_media_url(resp)
# 同基础资产:瞬时抖动重试,永久错误立即退费报错
response, media = _run_image_with_retry(_make)
with transaction.atomic():
task.status = AITask.Status.SUCCEEDED
task.response_payload = response