baodan/api/insurance/poster/image_generator.py
wsb1224 67b2dac23f slidesConfig 结构错误 — migrate_017 输出改为数组格式,migrate_019 修复历史数据
迁移系统加固 — 加 advisory lock 防并发,失败回滚并中止启动(原来是 catch-and-continue)
安全漏洞 — Settings/History API 权限收紧,API key 返回掩码值,海报 case 所有权校验
依赖缺失 — requirements.txt 补齐 python-pptx/openai/Pillow,Dockerfile 改为统一安装
海报鉴权下载 — 前端全部改用 authenticated blob,不再 window.open 无 token URL
LLM 配置分离 — 海报文案读取 poster_llm_*(不再复用 ppt_llm_*),支持 config namespace
图片生成器 — 兼容 b64_json 和 URL 两种响应格式,追踪 generation_mode/provider/model
种子数据 — 新环境自动获得 2 个海报模板 + 2 个文案模板
2026-07-27 13:21:34 +08:00

239 lines
8.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""海报图片生成器 — 支持多供应商生图模型OpenAI / 豆包 / 智谱等)。"""
import os
import base64
import logging
import tempfile
import httpx
logger = logging.getLogger(__name__)
# 用户尺寸 → API 尺寸映射
SIZE_MAP = {
"1080x1920": "1024x1792",
"900x500": "1792x1024",
"1080x1080": "1024x1024",
"800x1200": "1024x1792",
}
def _load_image_config() -> dict:
"""从数据库读取海报图片模型配置。"""
try:
from insurance.db.compat import db
from insurance.models.system_setting import SystemSetting
keys = ["poster_image_provider", "poster_image_model",
"poster_image_api_key", "poster_image_base_url"]
settings = {s.key: s.value for s in SystemSetting.query.filter(SystemSetting.key.in_(keys)).all()}
return settings
except Exception:
return {}
class PosterImageGenerator:
"""海报图片生成器,支持 OpenAI 兼容接口的多供应商生图模型。"""
def __init__(self):
self._client = None
self._config = None
def _get_config(self) -> dict:
if self._config is None:
self._config = _load_image_config()
return self._config
def _get_client(self):
if self._client is None:
import openai
cfg = self._get_config()
api_key = cfg.get("poster_image_api_key") or os.getenv("OPENAI_API_KEY", "")
base_url = cfg.get("poster_image_base_url") or os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
self._client = openai.OpenAI(api_key=api_key, base_url=base_url)
return self._client
def _get_model(self) -> str:
cfg = self._get_config()
return cfg.get("poster_image_model") or "gpt-image-1"
def build_prompt(self, template: dict = None, product: dict = None,
company: dict = None, copy: dict = None, size: str = "1024x1792") -> str:
"""组装完整的生图 prompt。"""
parts = [
"你是一位专业的保险营销海报设计师。请根据以下信息生成一张保险营销海报。",
"",
]
# 风格要求
if template:
parts.append("【风格要求】")
parts.append(template.get("styleDescription", ""))
if template.get("colorScheme"):
parts.append(f"配色方案:{template['colorScheme']}")
parts.append("")
# 产品信息
if product:
parts.append("【产品信息】")
parts.append(f"产品名称:{product.get('displayName', '')}")
parts.append(f"所属公司:{company.get('displayName', '') if company else ''}")
if product.get("manualParsedRules"):
rules = product["manualParsedRules"]
if isinstance(rules, str):
import json
try:
rules = json.loads(rules)
except Exception:
rules = {}
features = rules.get("features", [])
if features:
parts.append("产品亮点:" + "".join(f.get("title", "") for f in features[:3]))
parts.append("")
# 营销文案
if copy:
parts.append("【营销文案】")
if copy.get("headline"):
parts.append(f"标题:{copy['headline']}")
if copy.get("body"):
parts.append(f"正文:{copy['body']}")
if copy.get("call_to_action"):
parts.append(f"行动号召:{copy['call_to_action']}")
parts.append("")
# 输出要求
parts.append("【输出要求】")
parts.append(f"- 尺寸:{size} 像素")
parts.append("- 文字清晰可读,中文为主")
parts.append("- 符合保险行业专业风格")
parts.append("- 包含公司 Logo 位置(如有)")
return "\n".join(parts)
def _extract_image_bytes(self, response) -> bytes:
"""从图片 API 响应中提取图片 bytes支持 b64_json 和 URL 两种格式。"""
data_item = response.data[0] if response.data else None
if not data_item:
raise ValueError("图片 API 返回空数据")
# 优先使用 b64_json
if hasattr(data_item, 'b64_json') and data_item.b64_json:
return base64.b64decode(data_item.b64_json)
# 回退到 URL 下载
if hasattr(data_item, 'url') and data_item.url:
logger.info(f"图片 API 返回 URL正在下载: {data_item.url[:80]}...")
resp = httpx.get(data_item.url, timeout=60, follow_redirects=True)
resp.raise_for_status()
return resp.content
raise ValueError("图片 API 响应既无 b64_json 也无 url")
def generate(self, prompt: str, size: str = "1024x1792", reference_image: str = None) -> tuple:
"""调用配置的生图模型生成海报图片。
返回:
(image_bytes, provider_info) — PNG 图片的 bytes 和生成信息
"""
api_size = SIZE_MAP.get(size, "1024x1792")
client = self._get_client()
model = self._get_model()
provider_info = {"provider": self._get_config().get("poster_image_provider", "openai"), "model": model}
kwargs = {
"model": model,
"prompt": prompt,
"n": 1,
"size": api_size,
}
# 如果有参考图,尝试传入(仅允许 uploads 目录下的文件)
uploads_root = os.path.abspath("uploads")
if reference_image and os.path.exists(reference_image):
ref_abs = os.path.abspath(reference_image)
if not ref_abs.startswith(uploads_root):
logger.warning(f"参考图路径不在允许目录: {reference_image}")
reference_image = None
else:
try:
with open(reference_image, "rb") as img_file:
response = client.images.edit(
model=model,
image=img_file,
prompt=prompt,
n=1,
size=api_size,
)
return self._extract_image_bytes(response), provider_info
except Exception as e:
logger.warning(f"参考图 edit 失败,降级为纯 prompt 生成: {e}")
response = client.images.generate(**kwargs)
return self._extract_image_bytes(response), provider_info
def generate_fallback(copy_content: dict, size: str = "1024x1792") -> bytes:
"""降级方案:使用 Pillow 生成基础排版图。"""
from PIL import Image, ImageDraw, ImageFont
w, h = 1024, 1792
if "x" in size:
try:
parts = size.split("x")
w, h = int(parts[0]), int(parts[1])
except Exception:
pass
# 缩放到 API 支持的尺寸
w, h = min(w, 1792), min(h, 1792)
img = Image.new("RGB", (w, h), color=(26, 26, 46))
draw = ImageDraw.Draw(img)
# 简单排版
headline = copy_content.get("headline", "")
body = copy_content.get("body", "")
cta = copy_content.get("call_to_action", "")
# 尝试加载支持中文的字体
chinese_fonts = [
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
"/usr/share/fonts/wqy-zenhei/wqy-zenhei.ttc",
"C:/Windows/Fonts/msyh.ttc", # Windows 微软雅黑
"C:/Windows/Fonts/simsun.ttc", # Windows 宋体
]
font_large = font_medium = font_small = None
for font_path in chinese_fonts:
if os.path.exists(font_path):
try:
font_large = ImageFont.truetype(font_path, 48)
font_medium = ImageFont.truetype(font_path, 28)
font_small = ImageFont.truetype(font_path, 22)
break
except Exception:
continue
if font_large is None:
font_large = ImageFont.load_default()
font_medium = ImageFont.load_default()
font_small = ImageFont.load_default()
# 绘制文字
y = h // 4
if headline:
draw.text((w // 2, y), headline, fill="white", font=font_large, anchor="mm")
y += 80
if body:
# 简单换行
for line in body.split("\n")[:4]:
draw.text((w // 2, y), line, fill=(200, 200, 200), font=font_medium, anchor="mm")
y += 40
if cta:
draw.text((w // 2, h - 200), cta, fill=(102, 126, 234), font=font_small, anchor="mm")
import io
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()