feat: 添加新的模型,删除后端数据库

This commit is contained in:
Joey
2026-08-19 22:37:20 +08:00
parent 82cb694837
commit 6732cb178a
17 changed files with 411 additions and 918 deletions
+51 -109
View File
@@ -11,16 +11,13 @@ import base64
import logging
import mimetypes
import re
from uuid import UUID
import httpx
from sqlalchemy import select
from config import get_settings
from db import get_session_factory
from models import Product, ProductAsset, Suite, SuiteImage, SUITE_RUNNING, SUITE_DONE, SUITE_PARTIAL, SUITE_FAILED, STATUS_OK, STATUS_FAILED
from services import storage
from services.prompt import build_prompt, build_context, type_name, wrap_prompt_for_gpt_edits
from services.tasks import Task, TaskImage, TASK_FAILED, TASK_RUNNING, TASK_DONE, TASK_PARTIAL, IMG_OK
log = logging.getLogger("suite.generator")
@@ -242,22 +239,25 @@ async def generate_tongyi(prompt: str, ref_images: list[str], size: str = "2048*
# 可重试的状态码:中转限流/网关抖动(该中转限流时返回 Cloudflare 502 而非 429
RETRYABLE_STATUS = {429, 500, 502, 503, 504}
# 中转对 input_fidelity 参数的支持探测:None=未探测,True=支持,False=不支持(已降级)
_rightapi_fidelity_supported: bool | None = None
# 中转对 input_fidelity 参数的支持探测:按模型记忆不支持该参数的模型(gpt-image 系列支持,
# nano-banana 系列可能不认;降级只影响触发过的模型,不牵连其他模型)
_rightapi_fidelity_unsupported: set[str] = set()
async def _rightapi_request(s, prompt: str, ref_images: list[str], size: str, model: str) -> bytes:
"""gpt-image 系列:有参考图走 /v1/images/editsmultipart),无参考图走 /v1/images/generations。
"""RightAPI 各模型:有参考图走 /v1/images/editsmultipart),无参考图走 /v1/images/generations。
OpenAI 兼容协议:响应固定 b64_json(不支持 response_format 参数,传了报 400);
同步调用无任务轮询,高质量档单张 1-5 分钟,超时按文档建议兜底 600s。
input_fidelity=high 强制高保真保留输入图细节(商品一致性关键参数,仅 edits 端点);
中转若不认该参数(400),自动去掉重试并记住,后续请求不再带。
input_fidelity=high 是 gpt-image-1 的 edits 保真参数(gpt-image-2 官方已移除、默认高保真,
官逆通道更是不识别);带上是为了兼容按 gpt-image-1 语义实现的中转,中转不认(400)则按模型
自动去掉重试并记住,该模型后续请求不再带。
"""
global _rightapi_fidelity_supported
base = s.rightapi_base_url.rstrip("/")
headers = {"Authorization": f"Bearer {s.rightapi_api_key}"}
use_fidelity = bool(ref_images) and s.rightapi_input_fidelity and _rightapi_fidelity_supported is not False
use_fidelity = (
bool(ref_images) and s.rightapi_input_fidelity and model not in _rightapi_fidelity_unsupported
)
async with httpx.AsyncClient(timeout=max(s.request_timeout, 600), verify=False) as client:
common = {
@@ -276,14 +276,12 @@ async def _rightapi_request(s, prompt: str, ref_images: list[str], size: str, mo
data, mime = await _resolve_ref_bytes(u)
files.append(("image[]", (f"ref-{i + 1}.{mime.split('/')[-1]}", data, mime)))
resp = await client.post(f"{base}/v1/images/edits", headers=headers, files=files, data=common)
# 中转不认 input_fidelity:去掉参数重试一次(仅一次探测)
# 中转不认 input_fidelity:去掉参数重试一次(仅一次探测),降级只记到当前模型
if resp.status_code == 400 and use_fidelity and "input_fidelity" in resp.text:
_rightapi_fidelity_supported = False
log.warning("RightAPI 不支持 input_fidelity 参数,已自动去掉并降级(后续请求不再带)")
_rightapi_fidelity_unsupported.add(model)
log.warning("RightAPI 模型 %s 不支持 input_fidelity 参数,已自动去掉并降级(该模型后续请求不再带)", model)
common.pop("input_fidelity", None)
resp = await client.post(f"{base}/v1/images/edits", headers=headers, files=files, data=common)
elif resp.is_success and use_fidelity:
_rightapi_fidelity_supported = True
else:
resp = await client.post(
f"{base}/v1/images/generations",
@@ -365,123 +363,67 @@ def _refs_for_job(images: list[dict], job: dict) -> list[str]:
return _order_refs(pool, job.get("kind", ""))
async def _select_ref_images(db, product_id: UUID, type_id: str) -> list[str]:
"""商品路径:主图组前几张。转存完成的用本地文件,未完成的直接用源站 URL。"""
assets = (await db.scalars(
select(ProductAsset).where(
ProductAsset.product_id == product_id,
ProductAsset.group_key == "main",
ProductAsset.type == "img",
).order_by(ProductAsset.sort_order)
)).all()
refs = [a.stored_url or a.source_url for a in assets if (a.stored_url or a.source_url)]
if not refs:
raise RuntimeError("商品没有可用参考图(未采集主图)")
return _order_refs(refs, type_id)
# 串行生成队列:所有用户共享同一批 API key,并发生成会触发中转限流
# rightapi 同 key 分钟级冷却);同一时间只跑一个任务,其余保持 pending 排队。
_GEN_LOCK = asyncio.Lock()
async def run_suite(suite_id: str) -> None:
"""后台执行套图任务:逐张生成 → 落盘 → 记录;单张失败不中断。
两条路径:
- 无状态(product_id 为空):上下文与参考图来自请求自带的 context / ref_images
- 商品路径(兼容旧流程):从 product + product_assets 取
"""
async def run_suite(task: Task) -> None:
"""后台执行套图任务:排队 → 逐张生成 → 落盘 → 更新内存状态;单张失败不中断。"""
settings = get_settings()
async with get_session_factory()() as db:
suite = await db.get(Suite, UUID(suite_id))
if suite is None:
return
provider_name = task.provider or settings.image_provider
generator = GENERATORS.get(provider_name)
if generator is None:
task.status = TASK_FAILED
task.error = f"未知 provider: {provider_name}"
return
product = None
if suite.product_id:
product = await db.get(Product, suite.product_id)
if product is None:
suite.status = SUITE_FAILED
suite.error = "商品不存在"
await db.commit()
return
suite.status = SUITE_RUNNING
await db.commit()
provider_name = suite.provider or settings.image_provider
generator = GENERATORS.get(provider_name)
if generator is None:
suite.status = SUITE_FAILED
suite.error = f"未知 provider: {provider_name}"
await db.commit()
return
raw = suite.context if not product else (product.raw or {})
ctx = build_context(raw or {}, fallback_name=product.name if product else "")
model = suite.model or {
"tongyi": settings.dashscope_model,
"rightapi": settings.rightapi_image_model,
}.get(provider_name, settings.ark_image_model)
is_wan = provider_name == "tongyi" and _is_wan_model(model)
size = _image_size(provider_name, suite.ratio, is_wan=is_wan, model=model)
# 任务列表:方案(逐张)优先,旧路径按 types
if suite.plan:
jobs = [dict(j) for j in suite.plan]
else:
jobs = [
{"kind": t, "title": type_name(t), "detail": "", "prompt_hint": "", "variant_name": None}
for t in (suite.types or [])
]
ctx = build_context(task.context or {}, fallback_name="")
model = task.model or {
"tongyi": settings.dashscope_model,
"rightapi": settings.rightapi_image_model,
}.get(provider_name, settings.ark_image_model)
is_wan = provider_name == "tongyi" and _is_wan_model(model)
size = _image_size(provider_name, task.ratio, is_wan=is_wan, model=model)
jobs = [dict(j) for j in task.plan]
async with _GEN_LOCK:
task.status = TASK_RUNNING
ok, failed = 0, 0
failures: list[str] = []
for job in jobs:
type_id = job["kind"]
image_row = SuiteImage(
suite_id=suite.id,
type_id=type_id,
name=job.get("title") or type_name(type_id),
status=STATUS_FAILED,
)
db.add(image_row)
await db.flush()
image = TaskImage(type_id=type_id, name=job.get("title") or type_name(type_id))
task.images.append(image)
try:
prompt = build_prompt(
type_id, ctx, suite.style_set, suite.lang,
extra=job, style_prompt=suite.style_prompt, requirements=suite.requirements,
type_id, ctx, task.style_set, task.lang,
extra=job, style_prompt=task.style_prompt, requirements=task.requirements,
)
# gpt-image edits 语义:商品冻结契约前置,防止风格词改商品
# gpt-image edits 语义:商品冻结契约前置(含商品文字锚定),防止风格词改商品
if provider_name == "rightapi":
prompt = wrap_prompt_for_gpt_edits(prompt)
if product:
refs = await _select_ref_images(db, product.id, type_id)
else:
refs = _refs_for_job(list(suite.ref_images or []), job)
prompt = wrap_prompt_for_gpt_edits(prompt, ctx)
refs = _refs_for_job(list(task.ref_images or []), job)
data = await generator(prompt, refs, size=size, model=model)
# 部分中转不遵守 output_format(要 jpeg 回 PNG),按魔数定扩展名
ext = ".png" if data[:8] == b"\x89PNG\r\n\x1a\n" else ".jpg"
key = storage.write_bytes(data, key_prefix=f"suites/{suite.id}", ext=ext)
image_row.stored_url = storage.public_url(key)
image_row.status = STATUS_OK
key = storage.write_bytes(data, key_prefix=f"suites/{task.id}", ext=ext)
image.url = storage.public_url(key)
image.status = IMG_OK
ok += 1
except Exception as exc: # noqa: BLE001
log.exception("套图 %s 类型 %s 生成失败", suite_id, type_id)
err = str(exc)[:500]
image_row.error = err
failures.append(f"{job.get('title') or type_name(type_id)}{err[:200]}")
log.exception("套图 %s 类型 %s 生成失败", task.id, type_id)
image.error = str(exc)[:500]
failures.append(f"{job.get('title') or type_name(type_id)}{str(exc)[:200]}")
failed += 1
await db.commit()
suite.status = SUITE_DONE if failed == 0 else (SUITE_PARTIAL if ok > 0 else SUITE_FAILED)
task.status = TASK_DONE if failed == 0 else (TASK_PARTIAL if ok > 0 else TASK_FAILED)
if failed:
uniq = list(dict.fromkeys(failures)) # 去重保序
detail = "".join(uniq[:6])
if len(uniq) > 6:
detail += f";…等共 {failed} 张失败"
if ok == 0:
suite.error = f"全部生成失败。{detail}"
task.error = f"全部生成失败。{detail}"
else:
suite.error = f"部分生成失败({failed} 张)。{detail}"
from datetime import datetime, timezone
suite.finished_at = datetime.now(timezone.utc)
if product:
product.stage = "generated" # 商品路径才有的阶段升级
await db.commit()
task.error = f"部分生成失败({failed} 张)。{detail}"
+47 -50
View File
@@ -17,47 +17,28 @@ import re
STYLE_SETS: dict[int, dict] = {
1: {
"name": "高级质感大片",
"tone": "高端电商大片质感,柔和的方向性棚拍光,背景带细腻的浅渐变,材质纹理清晰可见,"
"色彩层次高级克制,商业画册级品质,构图干净、留白充足",
"name": "北欧极简",
"tone": "北欧极简风:浅灰或米白背景,柔和漫射光,低饱和色调,画面留白充足,构图克制干净",
"bg": "",
},
2: {
"name": "清新生活场景",
"tone": "明亮通透的生活场景摄影,自然窗光,柔和的低饱和居家环境,浅景深虚化,"
"真实自然的氛围感,绿植与暖色织物点缀,温馨有人气",
"name": "清新明亮",
"tone": "清新明亮风:明亮的白色到浅蓝渐变背景,高调光线,色彩明快通透,整体轻盈干净",
"bg": "",
},
3: {
"name": "极简白底规范",
"tone": "极简棚拍风格,纯净无缝的浅色背景,柔和均匀的无影布光,以商品为中心的严谨构图,"
"安静的高级感,画面只保留轻微的自然接触投影",
"name": "高级感深色",
"tone": "高级质感风:深灰或炭黑背景,戏剧性侧光打光,突出商品材质与光泽,沉稳高级",
"bg": "",
},
4: {
"name": "炫彩促销风",
"tone": "高能量促销风格,高饱和度色块背景搭配动感几何图形,强对比,节日大促海报氛围,"
"构图抢眼、视觉冲击力强",
"name": "暖调生活",
"tone": "温暖生活风:暖米色背景,暖色灯光氛围,温馨的家居质感,亲和力强",
"bg": "",
},
5: {
"name": "暗调轻奢",
"tone": "暗调轻奢质感,深炭灰色背景,轮廓光勾勒商品边缘,材质细节丰富,带轻微雾感,"
"如美术馆展陈般的呈现",
"bg": "",
},
6: {
"name": "俄式风情",
"tone": "俄式风情电商大片,浓郁温暖的色调,红与金的传统配色点缀,冬日节庆氛围,"
"深色木质与毛毡织物背景,如暖炉烛光般的柔和光晕,厚重扎实的质感,"
"带一丝巴洛克式的华丽细节,适合俄语区市场",
"bg": "",
},
7: {
"name": "北欧极简",
"tone": "北欧极简风格,白色与浅灰的原木空间,大量自然漫射光,干净利落的线条,"
"浅色木质背景点缀少量绿植,克制的中性配色,画面通透轻盈,"
"舒适宁静的氛围",
"name": "纯净棚拍",
"tone": "标准电商棚拍:纯色浅背景,均匀的正面柔光,无杂物干扰,商品居中突出",
"bg": "",
},
}
@@ -121,33 +102,49 @@ DEFAULT_NEGATIVE_INTENT = (
# "主体参考"),风格词会被字面执行到商品上。按 OpenAI 官方提示词指南的编辑模式:
# 按序号说明输入图、PRESERVE/MAY CHANGE 分列、首尾重申不变量、文案逐字渲染。
GPT_EDITS_CONTRACT = (
"INPUT IMAGES: Image 1 (and Image 2 if present) are reference photos of ONE product "
"from different angles. Use them ONLY as the source of the product's true appearance.\n"
"PRESERVE (frozen, never change): the product itself — silhouette, proportions, colors, "
"print/pattern (keep stripes / logos / labels exactly), materials, texture, stitching, "
"hardware and every design detail. The product in the output must be the same physical "
"item as in the input images, merely photographed in a new setting.\n"
"MAY CHANGE: background, scene, props, camera angle, lighting, composition "
"and in-image marketing typography.\n"
"STYLE SCOPE: all style, mood, color-palette and decoration instructions below describe "
"the SCENE AND BACKGROUND ONLY — never apply them to the product itself. "
"Do not restyle, recolor, re-pattern or redecorate the product. "
"You may relight the product so it sits naturally in the new scene "
"(matched shadows and color temperature), but never change its design, colors or pattern."
)
def gpt_edits_contract(ctx: dict) -> str:
"""gpt-image edits 语义契约(放开头,指令权重最高处)。
除通用锁定条款外,注入商品文字锚定(标题 + 关键参数 + 描述):
官逆通道(gpt-image-2-vip 等)会把参考图当对话附件弱化处理,
input_fidelity 类 API 参数不生效,此时商品文字描述是保真的唯一兜底。
"""
anchor = f" The product is: \"{ctx['title']}\""
if ctx.get("params_line"):
anchor += f" (key specs: {ctx['params_line']})"
if ctx.get("desc"):
anchor += f". {ctx['desc']}"
return (
"TASK: Edit the attached product photos — re-photograph THE SAME physical product "
"in a new setting. This is an edit of the input images, NOT a new product design.\n"
"INPUT IMAGES: Image 1 = product front view (PRIMARY source of truth for the product's "
f"true appearance); Image 2 (if present) = product back / detail view.{anchor}\n"
"PRODUCT LOCK (highest priority, overrides everything else in this prompt): exactly "
"preserve the product's shape, silhouette, proportions, colors, label text, logos, "
"print/pattern, materials and texture. Do not redesign, restyle, recolor, re-pattern "
"or substitute the product with a similar one. The output must show the very product "
"from the input images AND match the product description above; if the generated "
"product differs from either in any design detail, the image is rejected.\n"
"MAY CHANGE: background, scene, props, camera angle, lighting, composition "
"and in-image marketing typography only. You may relight the product so it sits "
"naturally in the new scene (matched shadows and color temperature).\n"
"STYLE SCOPE: all style, mood, color-palette and decoration instructions below describe "
"the SCENE AND BACKGROUND ONLY — never apply them to the product. "
"When any style instruction conflicts with product fidelity, product fidelity always wins."
)
GPT_EDITS_FINAL_CHECK = (
"FINAL CHECK before output: if the product in your result differs from the input product in any "
"design detail (shape, color, pattern, material, logo), the image is rejected. "
"Render any listed marketing copy / headlines exactly as written (verbatim, no extra characters, "
"no paraphrasing)."
"FINAL CHECK before output: if the product in your result differs from the input product "
"or the product description above in any design detail (shape, color, pattern, material, "
"logo), the image is rejected. Render any listed marketing copy / headlines exactly as "
"written (verbatim, no extra characters, no paraphrasing)."
)
def wrap_prompt_for_gpt_edits(prompt: str) -> str:
def wrap_prompt_for_gpt_edits(prompt: str, ctx: dict) -> str:
"""gpt-image edits 语义适配:契约放开头(指令权重最高处),终检放结尾。"""
return f"{GPT_EDITS_CONTRACT}\n\n{prompt}\n\n{GPT_EDITS_FINAL_CHECK}"
return f"{gpt_edits_contract(ctx)}\n\n{prompt}\n\n{GPT_EDITS_FINAL_CHECK}"
# ── 商品上下文提炼 ────────────────────────────────────────────────────────
+68
View File
@@ -0,0 +1,68 @@
"""内存任务注册表:套图生成任务的生命周期与进程一致(重启即新会话)。
轮询/导出只服务「当前会话正在跟踪的任务」——前端没有历史记录功能,
任务状态无需跨进程持久化;重启后轮询自然 404,前端提示任务已中断。
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass, field
# 任务状态
TASK_PENDING = "pending"
TASK_RUNNING = "running"
TASK_DONE = "done"
TASK_PARTIAL = "partial"
TASK_FAILED = "failed"
# 任务内单张图状态
IMG_OK = "ok"
IMG_FAILED = "failed"
@dataclass
class TaskImage:
"""任务里单张生成图:完成一张追加一条(前端进度 x/y 依赖此语义)。"""
type_id: str
name: str
status: str = IMG_FAILED # 循环里先建后跑,成功后改为 ok
url: str = ""
error: str | None = None
@dataclass
class Task:
"""一次套图生成任务:轮询可见字段 + 仅供 run_suite 消费的执行参数。"""
id: str
status: str = TASK_PENDING
platform: str = "cn"
lang: str = "zh"
ratio: str = "1:1"
style_set: int = 1
style_prompt: str | None = None
requirements: str | None = None
provider: str = ""
model: str | None = None
total: int = 0 # 计划总张数(进度分母)
images: list[TaskImage] = field(default_factory=list)
error: str | None = None
# ── 执行参数(不进轮询响应)──
context: dict = field(default_factory=dict) # 采集文本素材(build_context 的输入)
plan: list[dict] = field(default_factory=list) # 展开后的逐张任务
ref_images: list[dict] = field(default_factory=list) # 参考图池(main 优先)
# 进程内任务表:asyncio 单事件循环读写,无并发问题;不做淘汰(单会话量级很小)
_TASKS: dict[str, Task] = {}
def create_task(**kwargs) -> Task:
task = Task(id=uuid.uuid4().hex, **kwargs)
_TASKS[task.id] = task
return task
def get_task(task_id: str) -> Task | None:
return _TASKS.get(task_id)