上线版: 产品表单统一+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

@@ -43,6 +43,15 @@ class AIClients:
_model_image: str = "gpt-image-2"
_model_text: str = "claude-opus-4-8" # 最强档(倩倩姐红线):Claude系一律4.8,绝不降级
def has_alt_channel(self) -> bool:
"""是否真正配置了 codeproxy 备用通道(有独立 key 才算)。
🔴 没配 codeproxy key 时(用户未录 + env 未设),编排层据此跳过 codeproxy
避免每张图都白白尝试 codeproxy → 撞"绝不静默降级"红线 → 空转重试 3 次。
录了 key 就自动启用真双通道互备,逻辑不变。
"""
return bool(self._alt_token)
def _client(self) -> httpx.AsyncClient:
"""主通道(apiports) client按当前事件循环缓存"""
return self._client_for(self._gpt_base, self._gpt_token)
@@ -67,15 +76,33 @@ class AIClients:
# ── ImageClient 协议实现(供 image_gen.py 使用)────────
def _gpt_target(self, provider: str | None) -> tuple[str, str | None, httpx.AsyncClient]:
"""按 provider 选 (base, token, client)codeproxy 走备用站独立 base+key"""
if provider == "codeproxy" and self._alt_token:
"""按 provider 选 (base, token, client)codeproxy 走备用站独立 base+key
🔴 绝不静默降级(红线):显式点名 codeproxy 却没有 codeproxy 独立 key 时,
必须抛错让该通道明确失败(由上层 generate_one_image 记录并切下一通道),
绝不静默用 apiports 主 key 冒充 codeproxy——那等于"假双通道"且无声退化,
既骗了主备设计也违反红线。用户要真备份就得录入第二把 key。
"""
if provider == "codeproxy":
if not self._alt_token:
raise RuntimeError(
"codeproxy 通道未配置独立 key,拒绝用 apiports 主 key 冒充"
"(红线:绝不静默降级)。请为该用户录入 codeproxy key 以启用真双通道互备。"
)
base = (self._alt_base or os.environ.get("CODEPROXY_BASE_URL") or "").rstrip("/")
return base, self._alt_token, self._client_for(base, self._alt_token)
base = (os.environ.get("IMAGE_API_BASE") or os.environ.get("APIPORTS_BASE_URL") or "").rstrip("/")
return base, self._gpt_token, self._client_for(self._gpt_base, self._gpt_token)
async def gpt_edits(self, prompt: str, reference_images: list[bytes], size: str, provider: str | None = None) -> bytes:
"""GPT edits endpoint带产品参考图禁纯文生图"""
"""GPT edits endpoint带产品参考图禁纯文生图
codeproxy 走 OpenAI Images `/images/edits`apiports 实测图片编辑注册在
`/chat/completions` 多模态通道,不能复用 `/images/edits`。
"""
if provider == "apiports":
return await self._apiports_chat_image_edit(prompt, reference_images)
import io
files: list[tuple] = [("prompt", (None, prompt))]
for i, img in enumerate(reference_images):
@@ -87,6 +114,29 @@ class AIClients:
resp.raise_for_status()
return _extract_image_bytes(resp.json())
async def _apiports_chat_image_edit(self, prompt: str, reference_images: list[bytes]) -> bytes:
"""apiports 图片编辑:走 /chat/completions 多模态,参考图用 data URL。"""
if not reference_images:
raise ValueError("apiports 图生图缺少参考图,拒绝纯文生图")
import base64
base, _, client = self._gpt_target("apiports")
content: list[dict] = [{"type": "text", "text": prompt}]
for img in reference_images:
b64 = base64.b64encode(img).decode()
content.append({
"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{b64}"},
})
payload = {
"model": self._model_image,
"messages": [{"role": "user", "content": content}],
}
resp = await client.post(f"{base}/chat/completions", json=payload, timeout=300.0)
resp.raise_for_status()
return _extract_chat_image_bytes(resp.json())
async def gpt_generate(self, prompt: str, size: str, provider: str | None = None) -> bytes:
"""GPT 纯文生图(仅 ALLOW_TEXT_ONLY_IMAGE=true 时用)"""
base, _, client = self._gpt_target(provider)
@@ -100,14 +150,17 @@ class AIClients:
if not self._gemini_key:
raise RuntimeError("Gemini key 未初始化")
gemini_base = os.environ.get("GEMINI_API_URL", "https://generativelanguage.googleapis.com/v1beta")
url = f"{gemini_base}/models/{model}:generateContent?key={self._gemini_key}"
# 安全红线key 走 header(x-goog-api-key)不进 URL query
# 否则 httpx 异常的 __str__ 含完整 URL会把明文 key 带进 WARNING 日志。
url = f"{gemini_base}/models/{model}:generateContent"
parts: list[dict] = [{"text": prompt}]
for img in reference_images:
import base64
parts.append({"inline_data": {"mime_type": "image/png", "data": base64.b64encode(img).decode()}})
payload = {"contents": [{"role": "user", "parts": parts}], "generationConfig": {"responseModalities": ["IMAGE", "TEXT"]}}
async with httpx.AsyncClient() as client:
resp = await client.post(url, json=payload, timeout=120.0)
resp = await client.post(url, json=payload,
headers={"x-goog-api-key": self._gemini_key}, timeout=120.0)
resp.raise_for_status()
return _extract_gemini_image(resp.json())
@@ -126,9 +179,10 @@ class AIClients:
resp.raise_for_status()
return resp.json()
except (httpx.HTTPStatusError, httpx.TransportError, httpx.TimeoutException) as exc:
# 仅 5xx(服务端过载)或网络层错误才回落4xx(参数/鉴权)回落也没用,直接抛。
# 回落判定网络层错误、5xx(过载)、以及 402(欠费)/429(限流) 都该切通道——
# 这几种都是"本通道暂时不行、换通道有救"。401/403(鉴权)/400(参数) 回落也没用,直接抛。
status = getattr(getattr(exc, "response", None), "status_code", None)
retryable = status is None or status >= 500
retryable = status is None or status >= 500 or status in (402, 429)
if not (retryable and self._alt_token):
raise
alt_base = (self._alt_base or os.environ.get("CODEPROXY_BASE_URL") or "").rstrip("/")
@@ -196,21 +250,26 @@ class AIClients:
self._gpt_client_loop_id = None
def build_ai_clients(plain_key: str, gemini_key: str | None = None) -> AIClients:
def build_ai_clients(plain_key: str, gemini_key: str | None = None, alt_key: str | None = None) -> AIClients:
"""
用解密后的明文 key 构建 AIClients。
只在 Celery worker 函数体内调用plain_key 是局部变量。
httpx client 不在此预创建(避免绑死到调用方 loop首次 await 时按 loop 懒建。
调用完成后 caller 负责 await clients.aclose()。
alt_key用户录入的 codeproxy 备用站 key基石Bworker 内查库解密后传入)。
倩倩姐红线「全做成自己的不要埋进系统」——codeproxy key 优先用用户录入的,
未录入才回落 env CODEPROXY_KEY向后兼容避免没录时备用通道直接失效
base 地址(中转站 URL非秘密仍走 env 配置。
"""
gpt_base = (
os.environ.get("IMAGE_API_BASE") # 旧变量名
or os.environ.get("APIPORTS_BASE_URL") # .env 实际变量名
or ""
).rstrip("/")
# 备用站 codeproxy系统级 key非用户录入apiports 503 时切过去保生图成功
# 备用站 codeproxy优先用户录入 key回落 envapiports 503 时切过去保生图成功
alt_base = (os.environ.get("CODEPROXY_BASE_URL") or "").rstrip("/")
alt_token = os.environ.get("CODEPROXY_KEY") or None
alt_token = alt_key or os.environ.get("CODEPROXY_KEY") or None
return AIClients(
_gpt_token=plain_key,
_gpt_base=gpt_base or None,
@@ -240,6 +299,95 @@ def _extract_image_bytes(resp_json: dict) -> bytes:
raise ValueError(f"无法解析图片响应:{list(item.keys())}")
def _decode_b64_image(value: str) -> bytes:
"""解 data URL 或裸 base64 图片。"""
import base64
if value.startswith("data:"):
value = value.split(",", 1)[1]
return base64.b64decode(value)
def _bytes_from_image_item(item: dict) -> bytes | None:
"""兼容 chat 图片 item 里的常见 url/base64 字段。"""
if not isinstance(item, dict):
return None
for key in ("b64_json", "base64", "data"):
val = item.get(key)
if isinstance(val, str) and val:
return _decode_b64_image(val)
url_val = None
if isinstance(item.get("image_url"), dict):
url_val = item["image_url"].get("url")
elif isinstance(item.get("image_url"), str):
url_val = item.get("image_url")
elif isinstance(item.get("url"), str):
url_val = item.get("url")
if isinstance(url_val, str) and url_val:
if url_val.startswith("data:"):
return _decode_b64_image(url_val)
resp = httpx.get(url_val, timeout=30.0)
resp.raise_for_status()
return resp.content
return None
def _bytes_from_content_string(text: str) -> bytes | None:
"""从 chat 返回的正文字符串里抠图data URL / markdown 图链 / 裸图片URL。
很多中转站把 gpt-image 结果以 ![](url) 或 data URL 嵌在 content 文本里,
不走 images[]/parts 结构化字段,这里兜住这几种常见形态(前面带说明文字也能抠出)。
"""
import re
m = re.search(r"data:image/[\w.+-]+;base64,[A-Za-z0-9+/=]+", text)
if m:
return _decode_b64_image(m.group(0))
m = re.search(r"!\[[^\]]*\]\((https?://[^\s)]+)\)", text) # markdown 图链
if m:
resp = httpx.get(m.group(1), timeout=30.0)
resp.raise_for_status()
return resp.content
m = re.search( # 裸图片URL限图片后缀避免误抓非图链接
r"https?://[^\s)\]\"']+\.(?:png|jpe?g|webp|gif)(?:\?[^\s)\]\"']*)?",
text, re.IGNORECASE,
)
if m:
resp = httpx.get(m.group(0), timeout=30.0)
resp.raise_for_status()
return resp.content
return None
def _extract_chat_image_bytes(resp_json: dict) -> bytes:
"""从 /chat/completions 图片响应中提取图片 bytes。"""
choices = resp_json.get("choices") or []
if not choices:
raise ValueError("chat 图片响应缺少 choices")
message = choices[0].get("message") or {}
images = message.get("images")
if isinstance(images, list):
for item in images:
found = _bytes_from_image_item(item)
if found:
return found
content = message.get("content")
if isinstance(content, list):
for part in content:
found = _bytes_from_image_item(part)
if found:
return found
elif isinstance(content, str) and content:
found = _bytes_from_content_string(content)
if found:
return found
raise ValueError(
"无法解析 chat 图片响应:"
f"top={list(resp_json.keys())}, message={list(message.keys())}"
)
def _extract_gemini_image(resp_json: dict) -> bytes:
"""从 Gemini generateContent 响应提取图片 bytes"""
import base64