上线版: 产品表单统一+form嵌套修复+用户管理+部署+三套叙事
- 产品编辑入口统一走 ProductFormFull(卖点/风格/人群/品牌词全字段); 修复开任务页 <form> 套 <form> 致"编辑产品"报错、改不了、跳回首个产品 - dashboard 入口卡片对齐实际路由: 系统管理(/config) 与 工作配置(/settings) 分开; settings ?tab=products 直达改用挂载后读 URL, 消除 hydration mismatch - 新增用户管理(users API/admin service/改密页) + alembic 022/023/024 - 上线部署: Dockerfile / docker-compose.prod+https / nginx https / .env.example - A8 三套正交叙事(痛点/场景/成分背书) + beige 调色去AI化 + 飞轮 text_import 高权重信号 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -26,21 +26,37 @@ def _pick_reference_for_role(
|
||||
images_by_scene: dict[str, list[bytes]] | None,
|
||||
fallback: list[bytes] | None,
|
||||
) -> tuple[list[bytes] | None, str]:
|
||||
"""R5多图:按分镜 role 选该场景的产品图。取不到回落主图。
|
||||
"""R5多图:把【全部】产品参考图都传给每张分镜,按 role 偏好排序。
|
||||
|
||||
返回 (参考图bytes列表, 命中scene标签用于日志)。
|
||||
🔴 产品内外一体,绝不按 role 只喂单张(倩倩姐2026-06-26拍板纠正):
|
||||
旧逻辑命中偏好场景就 return 单张 → 质地特写分镜只拿到膏体微距、没有主图,
|
||||
模型缺完整瓶身锚点 → 脑补瓶身走样+乱印字("3张好/4张每套坏一张"的根因)。
|
||||
现改为每张分镜都把所有场景图一起传,只把本 role 最相关的场景排最前
|
||||
(多模态对靠前的图权重更高),且主图(primary)始终在列表内做瓶身锚点。
|
||||
|
||||
返回 (排序后的全部参考图bytes列表, 排序说明用于日志)。
|
||||
"""
|
||||
if images_by_scene:
|
||||
for scene in ROLE_SCENE_PREFERENCE.get(role, ["primary"]):
|
||||
prefs = ROLE_SCENE_PREFERENCE.get(role, ["primary"])
|
||||
ordered: list[bytes] = []
|
||||
used_scenes: list[str] = []
|
||||
# 1) 先按 role 偏好顺序排(本 role 最相关的视角靠前)
|
||||
for scene in prefs:
|
||||
imgs = images_by_scene.get(scene)
|
||||
if imgs:
|
||||
return imgs, scene
|
||||
# 偏好全落空:用任意可用图兜底(仍优先 primary)
|
||||
if images_by_scene.get("primary"):
|
||||
return images_by_scene["primary"], "primary"
|
||||
ordered.extend(imgs)
|
||||
used_scenes.append(scene)
|
||||
# 2) 主图始终保底进列表(即使不在偏好里,也要补做瓶身锚点)
|
||||
if "primary" not in used_scenes and images_by_scene.get("primary"):
|
||||
ordered.extend(images_by_scene["primary"])
|
||||
used_scenes.append("primary")
|
||||
# 3) 其余场景图也补进来:同一产品的所有视角都给模型看,防瓶身走样
|
||||
for scene, imgs in images_by_scene.items():
|
||||
if imgs:
|
||||
return imgs, f"{scene}(兜底)"
|
||||
if scene not in used_scenes and imgs:
|
||||
ordered.extend(imgs)
|
||||
used_scenes.append(scene)
|
||||
if ordered:
|
||||
return ordered, "+".join(used_scenes)
|
||||
return fallback, "fallback"
|
||||
|
||||
|
||||
@@ -53,17 +69,42 @@ class ImageClient(Protocol):
|
||||
async def gemini_generate(
|
||||
self, prompt: str, reference_images: list[bytes], model: str
|
||||
) -> bytes: ...
|
||||
def has_alt_channel(self) -> bool: ...
|
||||
|
||||
|
||||
def _image_provider_order() -> list[str]:
|
||||
"""从环境变量读主备顺序(扒 imageProviderOrder)"""
|
||||
def _image_provider_order(has_alt: bool = True) -> list[str]:
|
||||
"""从环境变量读主备顺序(扒 imageProviderOrder)。
|
||||
|
||||
🔴 真双通道互备(倩倩姐2026-06拍板):codeproxy / apiports 是两个独立中转站,
|
||||
各用自己的 base+key, 任一站上游挂(502/503)由另一站顶上 —— 这才是真备份。
|
||||
若 .env 把主备都设成同一个站(如都 codeproxy), 去重后只剩一个 → 退化成"假备份"
|
||||
(一站挂全挂)。故只要序列用到 GPT 中转站家族, 就自动补齐缺失的那个,
|
||||
保证 codeproxy+apiports 双通道都在。"gpt" 是 apiports 的等价别名(同 base+主key),
|
||||
归一化掉避免重复尝试同一站。codeproxy 走 /images/edits,apiports 走
|
||||
/chat/completions 多模态编辑;两者都必须带产品参考图,不碰纯文生图。
|
||||
|
||||
🔴 has_alt=False(用户/env 都没配 codeproxy key)时,绝不把 codeproxy 补进序列——
|
||||
否则每张图都白白尝试 codeproxy → 撞"绝不静默降级"红线 → 空转重试 3 次拖慢整体
|
||||
(倩倩姐2026-06-30方案A)。录了 key(has_alt=True)才补齐,真双通道逻辑不变。
|
||||
"""
|
||||
primary = os.environ.get("IMAGE_PROVIDER_PRIMARY", "gpt").lower()
|
||||
fallback = os.environ.get("IMAGE_PROVIDER_FALLBACK", "gemini").lower()
|
||||
seen: list[str] = []
|
||||
order: list[str] = []
|
||||
for p in [primary, fallback]:
|
||||
if p and p not in seen:
|
||||
seen.append(p)
|
||||
return seen
|
||||
p = "apiports" if p == "gpt" else p
|
||||
# 没配 codeproxy key 时,显式点名 codeproxy 也跳过(避免空转重试)
|
||||
if p == "codeproxy" and not has_alt:
|
||||
continue
|
||||
if p and p not in order:
|
||||
order.append(p)
|
||||
if any(p in ("codeproxy", "apiports") for p in order):
|
||||
for ch in ("codeproxy", "apiports"):
|
||||
# codeproxy 仅在真有备用 key 时补齐
|
||||
if ch == "codeproxy" and not has_alt:
|
||||
continue
|
||||
if ch not in order:
|
||||
order.append(ch)
|
||||
return order
|
||||
|
||||
|
||||
def _gemini_models() -> list[str]:
|
||||
@@ -90,12 +131,20 @@ async def _retry(coro_fn, attempts: int = IMAGE_RETRY_ATTEMPTS, backoff: float =
|
||||
async def _request_gpt(client: ImageClient, prompt: str, reference_images: list[bytes], provider: str | None = None) -> bytes:
|
||||
if reference_images:
|
||||
return await client.gpt_edits(prompt, reference_images, IMAGE_SIZE_DEFAULT, provider)
|
||||
# 无产品参考图时降级为纯文生图(需 ALLOW_TEXT_ONLY_IMAGE=true 或 M2阶段)
|
||||
allow_text_only = os.environ.get("ALLOW_TEXT_ONLY_IMAGE", "true").lower() == "true"
|
||||
if allow_text_only:
|
||||
logger.warning("无产品参考图,降级为纯文生图(可能产品跑偏,建议前端上传参考图)")
|
||||
return await client.gpt_generate(prompt, IMAGE_SIZE_DEFAULT, provider)
|
||||
raise ValueError("GPT 主通道缺产品图:禁止纯文生图以免产品跑偏(设 ALLOW_TEXT_ONLY_IMAGE=true 可解锁)")
|
||||
# 🔴 红线:瓶身忠于参考图原样,禁止纯文生图脑补产品(倩倩姐2026-06-15拍板)。
|
||||
# ALLOW_TEXT_ONLY_IMAGE 默认 false,即使显式设为 true 也拒绝——纯文生图会让产品包装
|
||||
# 完全由模型自由发挥,严重走样,不符合合规与真实双重要求。
|
||||
# 若要解锁请联系产品负责人,不在代码层开放。
|
||||
_env_val = os.environ.get("ALLOW_TEXT_ONLY_IMAGE", "false").lower()
|
||||
if _env_val == "true":
|
||||
logger.warning(
|
||||
"检测到 ALLOW_TEXT_ONLY_IMAGE=true,但该选项已被锁定(红线:禁纯文生图)。"
|
||||
"将强制抛出缺图异常,请为产品上传参考图。"
|
||||
)
|
||||
raise ValueError(
|
||||
"缺少产品参考图,无法生成忠于实物的产品图,请先上传该产品的主图。"
|
||||
"(红线:禁止纯文生图脑补产品包装,ALLOW_TEXT_ONLY_IMAGE 已锁定为无效)"
|
||||
)
|
||||
|
||||
|
||||
async def _request_gemini(client: ImageClient, prompt: str, reference_images: list[bytes]) -> bytes:
|
||||
@@ -118,7 +167,9 @@ async def generate_one_image(
|
||||
返回图片 bytes(PNG/JPEG)。
|
||||
"""
|
||||
refs = reference_images or []
|
||||
providers = _image_provider_order()
|
||||
# 没配 codeproxy 备用 key 时不把它补进尝试序列(避免每张图空转重试,方案A)
|
||||
has_alt = bool(getattr(client, "has_alt_channel", lambda: True)())
|
||||
providers = _image_provider_order(has_alt)
|
||||
errors: list[str] = []
|
||||
|
||||
for provider in providers:
|
||||
@@ -191,6 +242,9 @@ async def generate_storyboard_images(
|
||||
f"素材使用={item.get('asset_use','')}。"
|
||||
f"{brand_line}"
|
||||
f"禁止事项={item.get('forbidden','')}。"
|
||||
"参考图说明:随附的多张参考图都是【同一个产品】的不同视角(主图=产品/包装本体,"
|
||||
"其余=质地/细节/场景特写);产品外形、颜色、包装上原有文字必须100%忠于主图原样,"
|
||||
"禁止重绘产品本体、禁止在包装上增删改任何文字(品牌词只放图上标题/贴纸等排版层,绝不印产品本体)。"
|
||||
"排版要求:独立小红书3:4图文海报,画面完整;标题只出现一次,不与其他页重复;"
|
||||
"中文文字少而清晰,主标题+最多3个短点位;可自然用✅✨🌿💧🪞🧴📦🔍种草符号但不堆砌;"
|
||||
"不要生成App截图或笔记详情页界面。"
|
||||
|
||||
Reference in New Issue
Block a user