上线版: 产品表单统一+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:
yangqianqian
2026-06-30 18:08:13 +08:00
parent a77212781c
commit df1856d793
150 changed files with 8616 additions and 1765 deletions

View File

@@ -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/editsapiports 走
/chat/completions 多模态编辑;两者都必须带产品参考图,不碰纯文生图。
🔴 has_alt=False用户/env 都没配 codeproxy key绝不把 codeproxy 补进序列——
否则每张图都白白尝试 codeproxy → 撞"绝不静默降级"红线 → 空转重试 3 次拖慢整体
倩倩姐2026-06-30方案A。录了 keyhas_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(
返回图片 bytesPNG/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截图或笔记详情页界面。"