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 个文案模板
This commit is contained in:
parent
48f6388554
commit
67b2dac23f
@ -187,10 +187,10 @@ def delete_copy_template(template_id):
|
||||
return jsonify(ppt_admin_service.delete_copy_template(template_id))
|
||||
|
||||
|
||||
# ---- 历史记录管理 ----
|
||||
# ---- 历史记录管理(需要审计权限) ----
|
||||
|
||||
@ppt_admin_bp.route("/history", methods=["GET"])
|
||||
@jwt_required
|
||||
@permission_required("audit_view")
|
||||
def list_history():
|
||||
params = {
|
||||
"page": request.args.get("page", 1, type=int),
|
||||
@ -209,7 +209,7 @@ def delete_history(history_id):
|
||||
|
||||
|
||||
@ppt_admin_bp.route("/history/export", methods=["GET"])
|
||||
@jwt_required
|
||||
@permission_required("audit_view")
|
||||
def export_history():
|
||||
from flask import Response
|
||||
params = {
|
||||
@ -228,13 +228,13 @@ def export_history():
|
||||
# ---- 系统配置 ----
|
||||
|
||||
@ppt_admin_bp.route("/available-models", methods=["GET"])
|
||||
@jwt_required
|
||||
@permission_required("config_manage")
|
||||
def get_available_models():
|
||||
return jsonify(ppt_admin_service.get_available_models())
|
||||
|
||||
|
||||
@ppt_admin_bp.route("/settings", methods=["GET"])
|
||||
@jwt_required
|
||||
@permission_required("config_manage")
|
||||
def get_settings():
|
||||
return jsonify(ppt_admin_service.get_settings())
|
||||
|
||||
|
||||
@ -549,21 +549,52 @@ class PptAdminService:
|
||||
logger.warning(f"从 Dify 数据库获取模型列表失败: {e}")
|
||||
return None
|
||||
|
||||
# 需要掩码处理的敏感 key(包含 api_key 或 secret 等)
|
||||
_SENSITIVE_KEYS = {"api_key", "secret", "password", "token"}
|
||||
|
||||
@staticmethod
|
||||
def _mask_value(key: str, value: str) -> str:
|
||||
"""对敏感配置项进行掩码,不返回完整密钥。"""
|
||||
if not value or len(value) < 8:
|
||||
return value
|
||||
key_lower = key.lower()
|
||||
if any(s in key_lower for s in PptAdminService._SENSITIVE_KEYS):
|
||||
return f"{value[:4]}****{value[-4:]}"
|
||||
return value
|
||||
|
||||
def get_settings(self) -> dict:
|
||||
settings = SystemSetting.query.all()
|
||||
data = {}
|
||||
for s in settings:
|
||||
if any(kw in s.key.lower() for kw in self._SENSITIVE_KEYS):
|
||||
data[s.key] = self._mask_value(s.key, s.value)
|
||||
else:
|
||||
data[s.key] = s.value
|
||||
return {
|
||||
"code": 0,
|
||||
"data": {s.key: s.value for s in settings},
|
||||
"data": data,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _is_masked_value(value: str) -> bool:
|
||||
"""判断值是否为掩码格式(如 sk-****abcd)。"""
|
||||
return isinstance(value, str) and "****" in value
|
||||
|
||||
def update_settings(self, data: dict, updated_by: str = "") -> dict:
|
||||
for key, value in data.items():
|
||||
value_str = str(value) if value is not None else ""
|
||||
setting = SystemSetting.query.filter_by(key=key).first()
|
||||
if setting:
|
||||
setting.value = str(value)
|
||||
# 如果是掩码值,跳过更新(保留真实密钥)
|
||||
if self._is_masked_value(value_str):
|
||||
continue
|
||||
setting.value = value_str
|
||||
setting.updated_by = updated_by
|
||||
else:
|
||||
db.session.add(SystemSetting(key=key, value=str(value), updated_by=updated_by))
|
||||
# 新建设置项,不允许掩码值
|
||||
if self._is_masked_value(value_str):
|
||||
continue
|
||||
db.session.add(SystemSetting(key=key, value=value_str, updated_by=updated_by))
|
||||
db.session.commit()
|
||||
return {"code": 0, "data": None}
|
||||
|
||||
|
||||
@ -22,18 +22,49 @@ def _ensure_migration_table():
|
||||
from insurance.db.compat import db
|
||||
from sqlalchemy import text
|
||||
|
||||
db.session.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS db_migration_history (
|
||||
id SERIAL PRIMARY KEY,
|
||||
migration_name VARCHAR(255) UNIQUE NOT NULL,
|
||||
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""))
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def _acquire_advisory_lock():
|
||||
"""PostgreSQL advisory lock,防止多 Worker 并发执行迁移。
|
||||
|
||||
返回 True 表示获得锁,False 表示另一个进程正在迁移。
|
||||
"""
|
||||
from insurance.db.compat import db
|
||||
from sqlalchemy import text
|
||||
|
||||
try:
|
||||
# 创建迁移历史表(如果不存在)
|
||||
db.session.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS db_migration_history (
|
||||
id SERIAL PRIMARY KEY,
|
||||
migration_name VARCHAR(255) UNIQUE NOT NULL,
|
||||
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""))
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
logger.warning(f"创建迁移历史表失败: {e}")
|
||||
result = db.session.execute(text("SELECT pg_try_advisory_lock(20260727)"))
|
||||
locked = result.scalar()
|
||||
return bool(locked)
|
||||
except Exception:
|
||||
# SQLite 或其他数据库不支持 advisory lock,用文件锁替代
|
||||
import fcntl
|
||||
lock_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".migration.lock")
|
||||
try:
|
||||
lock_fd = open(lock_path, "w")
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
return True
|
||||
except (IOError, OSError):
|
||||
return False
|
||||
|
||||
|
||||
def _release_advisory_lock():
|
||||
"""释放 advisory lock。"""
|
||||
from insurance.db.compat import db
|
||||
from sqlalchemy import text
|
||||
|
||||
try:
|
||||
db.session.execute(text("SELECT pg_advisory_unlock(20260727)"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _get_executed_migrations():
|
||||
@ -55,22 +86,16 @@ def _record_migration(migration_name: str):
|
||||
from insurance.db.compat import db
|
||||
from sqlalchemy import text
|
||||
|
||||
try:
|
||||
db.session.execute(text(
|
||||
"INSERT INTO db_migration_history (migration_name, executed_at) VALUES (:name, :now)"
|
||||
), {"name": migration_name, "now": datetime.now()})
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
logger.warning(f"记录迁移 {migration_name} 失败: {e}")
|
||||
try:
|
||||
db.session.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
db.session.execute(text(
|
||||
"INSERT INTO db_migration_history (migration_name, executed_at) VALUES (:name, :now)"
|
||||
), {"name": migration_name, "now": datetime.now()})
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def run_migrations():
|
||||
"""自动执行所有迁移脚本(带幂等保护)。"""
|
||||
"""自动执行所有迁移脚本(带幂等保护、事务回滚和并发锁)。"""
|
||||
from flask import current_app
|
||||
from insurance.db.compat import db
|
||||
|
||||
# 检查是否启用自动迁移
|
||||
if not current_app.config.get("MIGRATION_ENABLED", False):
|
||||
@ -78,38 +103,52 @@ def run_migrations():
|
||||
|
||||
logger.info("开始执行数据库迁移...")
|
||||
|
||||
# 确保迁移历史表存在
|
||||
_ensure_migration_table()
|
||||
# 获取 advisory lock,防止多 Worker 并发迁移
|
||||
if not _acquire_advisory_lock():
|
||||
logger.info("另一个进程正在执行迁移,跳过")
|
||||
return
|
||||
|
||||
# 获取已执行的迁移
|
||||
executed = _get_executed_migrations()
|
||||
try:
|
||||
# 确保迁移历史表存在
|
||||
_ensure_migration_table()
|
||||
|
||||
# 获取当前目录
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
# 获取已执行的迁移
|
||||
executed = _get_executed_migrations()
|
||||
|
||||
# 扫描所有迁移脚本(migrate_*.py)
|
||||
migrations = []
|
||||
for filename in sorted(os.listdir(current_dir)):
|
||||
if filename.startswith("migrate_") and filename.endswith(".py"):
|
||||
migrations.append(filename[:-3]) # 去掉 .py 后缀
|
||||
# 获取当前目录
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# 按顺序执行未执行的迁移
|
||||
for migration_name in migrations:
|
||||
if migration_name in executed:
|
||||
logger.debug(f"跳过已执行的迁移: {migration_name}")
|
||||
continue
|
||||
try:
|
||||
module = importlib.import_module(f"insurance.db.{migration_name}")
|
||||
if hasattr(module, "migrate"):
|
||||
logger.info(f"执行迁移: {migration_name}")
|
||||
module.migrate()
|
||||
# 记录迁移已执行
|
||||
_record_migration(migration_name)
|
||||
except Exception as e:
|
||||
logger.warning(f"迁移 {migration_name} 失败: {e}")
|
||||
# 继续执行下一个迁移,不中断
|
||||
# 扫描所有迁移脚本(migrate_*.py)
|
||||
migrations = []
|
||||
for filename in sorted(os.listdir(current_dir)):
|
||||
if filename.startswith("migrate_") and filename.endswith(".py"):
|
||||
migrations.append(filename[:-3])
|
||||
|
||||
logger.info("迁移执行完成")
|
||||
# 按顺序执行未执行的迁移
|
||||
for migration_name in migrations:
|
||||
if migration_name in executed:
|
||||
logger.debug(f"跳过已执行的迁移: {migration_name}")
|
||||
continue
|
||||
try:
|
||||
module = importlib.import_module(f"insurance.db.{migration_name}")
|
||||
if hasattr(module, "migrate"):
|
||||
logger.info(f"执行迁移: {migration_name}")
|
||||
module.migrate()
|
||||
_record_migration(migration_name)
|
||||
logger.info(f"迁移 {migration_name} 完成")
|
||||
except Exception as e:
|
||||
# 回滚残留事务
|
||||
try:
|
||||
db.session.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
logger.error(f"迁移 {migration_name} 失败: {e}")
|
||||
# 失败后停止,不带病继续
|
||||
raise RuntimeError(f"迁移 {migration_name} 失败,中止启动: {e}") from e
|
||||
|
||||
logger.info("迁移执行完成")
|
||||
finally:
|
||||
_release_advisory_lock()
|
||||
|
||||
|
||||
def get_migration_status():
|
||||
|
||||
@ -132,7 +132,7 @@ def _build_default_slides_config(page_types, plan_type):
|
||||
meta = dict(defaults.get(pt, {"pageType": pt}))
|
||||
slides.append(meta)
|
||||
|
||||
return {"slides": slides}
|
||||
return slides
|
||||
|
||||
|
||||
def downgrade(engine):
|
||||
|
||||
@ -20,6 +20,9 @@ class PosterRecord(db.Model):
|
||||
export_size = Column(String(20), nullable=True, comment="导出尺寸")
|
||||
reference_image_used = Column(String(500), nullable=True, comment="使用的参考图(合规留痕)")
|
||||
prompt_used = Column(Text, nullable=True, comment="完整 prompt(合规留痕)")
|
||||
generation_mode = Column(String(20), nullable=True, comment="生成方式: ai/fallback")
|
||||
image_provider = Column(String(50), nullable=True, comment="图片供应商")
|
||||
image_model = Column(String(100), nullable=True, comment="图片模型名称")
|
||||
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||
|
||||
def to_dict(self):
|
||||
@ -38,5 +41,8 @@ class PosterRecord(db.Model):
|
||||
"exportSize": self.export_size,
|
||||
"referenceImageUsed": self.reference_image_used,
|
||||
"promptUsed": self.prompt_used,
|
||||
"generationMode": self.generation_mode,
|
||||
"imageProvider": self.image_provider,
|
||||
"imageModel": self.image_model,
|
||||
"createdAt": self.created_at.isoformat() if self.created_at else None,
|
||||
}
|
||||
|
||||
@ -58,7 +58,7 @@ class CopyGenerator:
|
||||
返回:
|
||||
{"headline": "...", "body": "...", "call_to_action": "..."}
|
||||
"""
|
||||
from insurance.ppt.llm_client import llm_client
|
||||
from insurance.ppt.llm_client import poster_llm_client
|
||||
|
||||
system_prompt = f"""你是一位专业的保险营销文案撰写人。
|
||||
根据以下产品信息和客户数据,生成一张保险营销海报的文案。
|
||||
@ -76,7 +76,7 @@ class CopyGenerator:
|
||||
产品信息:{json.dumps(product_rules, ensure_ascii=False)}
|
||||
客户数据:{json.dumps(customer_data, ensure_ascii=False)}"""
|
||||
|
||||
result, _response = await llm_client.structured_output(
|
||||
result, _response = await poster_llm_client.structured_output(
|
||||
user_prompt, system_prompt,
|
||||
schema={
|
||||
"type": "object",
|
||||
|
||||
@ -2,6 +2,9 @@
|
||||
import os
|
||||
import base64
|
||||
import logging
|
||||
import tempfile
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -106,15 +109,35 @@ class PosterImageGenerator:
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
def generate(self, prompt: str, size: str = "1024x1792", reference_image: str = None) -> bytes:
|
||||
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:
|
||||
"""调用配置的生图模型生成海报图片。
|
||||
|
||||
返回:
|
||||
PNG 图片的 bytes
|
||||
(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,
|
||||
@ -130,23 +153,22 @@ class PosterImageGenerator:
|
||||
if not ref_abs.startswith(uploads_root):
|
||||
logger.warning(f"参考图路径不在允许目录: {reference_image}")
|
||||
reference_image = None
|
||||
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,
|
||||
)
|
||||
image_base64 = response.data[0].b64_json
|
||||
return base64.b64decode(image_base64)
|
||||
except Exception as e:
|
||||
logger.warning(f"参考图 edit 失败,降级为纯 prompt 生成: {e}")
|
||||
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)
|
||||
image_base64 = response.data[0].b64_json
|
||||
return base64.b64decode(image_base64)
|
||||
return self._extract_image_bytes(response), provider_info
|
||||
|
||||
|
||||
def generate_fallback(copy_content: dict, size: str = "1024x1792") -> bytes:
|
||||
|
||||
@ -176,6 +176,14 @@ class PosterService:
|
||||
product_id = data.get("productId")
|
||||
reference_image = data.get("referenceImage")
|
||||
|
||||
# 校验 case 所有权(SEC-P0-03)
|
||||
if case_upload_id:
|
||||
case = PosterCaseUpload.query.get(case_upload_id)
|
||||
if not case or case.user_id != user_id:
|
||||
return {"code": 404, "message": "记录不存在", "data": None}
|
||||
if case.confirmed_data is None:
|
||||
return {"code": 1002, "message": "请先确认解析数据", "data": None}
|
||||
|
||||
# 获取模板
|
||||
poster_template = PosterTemplate.query.get(template_id) if template_id else None
|
||||
product = PptProduct.query.get(product_id) if product_id else None
|
||||
@ -193,12 +201,15 @@ class PosterService:
|
||||
)
|
||||
|
||||
# 生成图片
|
||||
generation_mode = "ai"
|
||||
provider_info = {}
|
||||
try:
|
||||
image_bytes = generator.generate(prompt, size=size, reference_image=reference_image)
|
||||
image_bytes, provider_info = generator.generate(prompt, size=size, reference_image=reference_image)
|
||||
except Exception as e:
|
||||
logger.warning(f"GPT image API 失败,使用降级方案: {e}")
|
||||
from insurance.poster.image_generator import generate_fallback
|
||||
image_bytes = generate_fallback(copy_content, size=size)
|
||||
generation_mode = "fallback"
|
||||
|
||||
# 保存文件
|
||||
output_dir = os.path.join(current_app.config.get("UPLOAD_FOLDER", "uploads"), "posters")
|
||||
@ -224,6 +235,9 @@ class PosterService:
|
||||
export_size=size,
|
||||
reference_image_used=reference_image,
|
||||
prompt_used=prompt[:2000] if prompt else None,
|
||||
generation_mode=generation_mode,
|
||||
image_provider=provider_info.get("provider", ""),
|
||||
image_model=provider_info.get("model", ""),
|
||||
)
|
||||
db.session.add(record)
|
||||
db.session.commit()
|
||||
|
||||
@ -289,13 +289,17 @@ async def _call_dify(
|
||||
# ─── 统一客户端 ───────────────────────────────────────────
|
||||
|
||||
class LLMClient:
|
||||
"""多供应商 LLM 客户端,支持自动切换和速率限制。"""
|
||||
"""多供应商 LLM 客户端,支持自动切换和速率限制。
|
||||
|
||||
def __init__(self):
|
||||
config_prefix: 数据库配置键前缀,如 "ppt" 读取 ppt_llm_*,"poster" 读取 poster_llm_*。
|
||||
"""
|
||||
|
||||
def __init__(self, config_prefix: str = "ppt"):
|
||||
self._configs: list[tuple[LLMProviderConfig, str]] = []
|
||||
self._limiters: dict[str, RateLimiter] = {}
|
||||
self._active_idx = 0
|
||||
self._timeout_ms = _parse_timeout_ms(os.getenv("PPT_LLM_TIMEOUT_MS"))
|
||||
self._config_prefix = config_prefix
|
||||
self._timeout_ms = _parse_timeout_ms(os.getenv(f"{config_prefix.upper()}_LLM_TIMEOUT_MS"))
|
||||
self._db_config_time: float = 0 # 上次从数据库加载配置的时间戳
|
||||
self._db_config_ttl: float = 60 # 配置缓存有效期(秒)
|
||||
|
||||
@ -303,7 +307,8 @@ class LLMClient:
|
||||
|
||||
def _load_env_config(self):
|
||||
"""从环境变量加载默认配置。"""
|
||||
deepseek_key = os.environ.get("DEEPSEEK_API_KEY") or os.environ.get("OPENAI_API_KEY", "")
|
||||
prefix = self._config_prefix.upper()
|
||||
deepseek_key = os.environ.get(f"{prefix}_LLM_API_KEY") or os.environ.get("DEEPSEEK_API_KEY") or os.environ.get("OPENAI_API_KEY", "")
|
||||
minimax_key = os.environ.get("MINIMAX_API_KEY", "")
|
||||
gemini_key = os.environ.get("GEMINI_API_KEY", "")
|
||||
|
||||
@ -326,23 +331,25 @@ class LLMClient:
|
||||
if now - self._db_config_time < self._db_config_ttl:
|
||||
return
|
||||
self._db_config_time = now
|
||||
prefix = self._config_prefix
|
||||
try:
|
||||
from insurance.models.system_setting import SystemSetting
|
||||
settings = {s.key: s.value for s in SystemSetting.query.filter(
|
||||
SystemSetting.key.in_([
|
||||
"ppt_llm_provider", "ppt_llm_model", "ppt_llm_api_key", "ppt_llm_base_url",
|
||||
"ppt_llm_timeout_ms",
|
||||
f"{prefix}_llm_provider", f"{prefix}_llm_model",
|
||||
f"{prefix}_llm_api_key", f"{prefix}_llm_base_url",
|
||||
f"{prefix}_llm_timeout_ms",
|
||||
])
|
||||
).all()}
|
||||
self._timeout_ms = _parse_timeout_ms(
|
||||
settings.get("ppt_llm_timeout_ms"),
|
||||
_parse_timeout_ms(os.getenv("PPT_LLM_TIMEOUT_MS")),
|
||||
settings.get(f"{prefix}_llm_timeout_ms"),
|
||||
_parse_timeout_ms(os.getenv(f"{prefix.upper()}_LLM_TIMEOUT_MS")),
|
||||
)
|
||||
provider = settings.get("ppt_llm_provider", "").strip()
|
||||
provider = settings.get(f"{prefix}_llm_provider", "").strip()
|
||||
if not provider:
|
||||
return
|
||||
|
||||
model = settings.get("ppt_llm_model", "").strip()
|
||||
model = settings.get(f"{prefix}_llm_model", "").strip()
|
||||
|
||||
# Dify 模式:通过 Dify Chat API 调用,无需独立 API Key
|
||||
if provider == "dify":
|
||||
@ -352,14 +359,14 @@ class LLMClient:
|
||||
self._configs = [(cfg, "")]
|
||||
self._limiters = {"dify": RateLimiter(0)}
|
||||
self._active_idx = 0
|
||||
logger.info(f"[LLMClient] 使用 Dify 模式: {model}")
|
||||
logger.info(f"[LLMClient:{prefix}] 使用 Dify 模式: {model}")
|
||||
return
|
||||
|
||||
api_key = settings.get("ppt_llm_api_key", "").strip()
|
||||
api_key = settings.get(f"{prefix}_llm_api_key", "").strip()
|
||||
if not api_key:
|
||||
return
|
||||
|
||||
base_url = settings.get("ppt_llm_base_url", "").strip()
|
||||
base_url = settings.get(f"{prefix}_llm_base_url", "").strip()
|
||||
|
||||
# 内置供应商:替换对应配置
|
||||
if provider in PROVIDERS and not base_url:
|
||||
@ -372,7 +379,7 @@ class LLMClient:
|
||||
self._configs = [(cfg, api_key)]
|
||||
self._limiters = {provider: RateLimiter(cfg.rate_limit)}
|
||||
self._active_idx = 0
|
||||
logger.info(f"[LLMClient] 使用数据库配置: {provider}/{cfg.model}")
|
||||
logger.info(f"[LLMClient:{prefix}] 使用数据库配置: {provider}/{cfg.model}")
|
||||
return
|
||||
|
||||
# 自定义供应商
|
||||
@ -384,7 +391,7 @@ class LLMClient:
|
||||
self._configs = [(cfg, api_key)]
|
||||
self._limiters = {provider: RateLimiter(0)}
|
||||
self._active_idx = 0
|
||||
logger.info(f"[LLMClient] 使用自定义模型: {provider}/{cfg.model}")
|
||||
logger.info(f"[LLMClient:{prefix}] 使用自定义模型: {provider}/{cfg.model}")
|
||||
except Exception:
|
||||
pass # 无 Flask 上下文或数据库不可用,使用环境变量配置
|
||||
|
||||
@ -438,7 +445,10 @@ class LLMClient:
|
||||
"""多供应商自动切换调用。"""
|
||||
self._try_load_db_config()
|
||||
if not self._configs:
|
||||
return LLMResponse(content="{}", provider="mock", latency_ms=0)
|
||||
raise RuntimeError(
|
||||
"未配置任何 LLM API Key,请在管理后台 > 系统配置中设置 "
|
||||
"ppt_llm_provider / ppt_llm_api_key,或设置环境变量 DEEPSEEK_API_KEY / OPENAI_API_KEY"
|
||||
)
|
||||
|
||||
start_idx = self._active_idx
|
||||
tried = set()
|
||||
@ -487,4 +497,5 @@ class LLMClient:
|
||||
|
||||
|
||||
# ─── 单例 ────────────────────────────────────────────────
|
||||
llm_client = LLMClient()
|
||||
llm_client = LLMClient(config_prefix="ppt")
|
||||
poster_llm_client = LLMClient(config_prefix="poster")
|
||||
|
||||
@ -35,3 +35,10 @@ PyMuPDF>=1.23.0
|
||||
PyPDF2>=3.0.0
|
||||
pypdf>=3.0.0
|
||||
pdfplumber>=0.9.0
|
||||
|
||||
# PPT generation (required for fast_pptx_renderer)
|
||||
python-pptx>=0.6.23
|
||||
|
||||
# Image generation (required for poster AI image and fallback)
|
||||
openai>=1.6.0
|
||||
Pillow>=10.0.0
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# 保险智能客服系统 — 文档索引
|
||||
|
||||
> **最后更新**:2026-07-23
|
||||
> **最后更新**:2026-07-27
|
||||
> **项目状态**:后端框架完成 + 核心功能完成 64%,产品推荐功能待实现
|
||||
|
||||
---
|
||||
@ -47,6 +47,7 @@
|
||||
|
||||
| 文档 | 内容摘要 |
|
||||
|------|---------|
|
||||
| [PPT与海报功能问题整改计划.md](PPT与海报功能问题整改计划.md) | 当前问题审计、根因、文件级修复方案、测试、发布与回滚计划 |
|
||||
| [PPT与海报功能完整解决方案.md](PPT与海报功能完整解决方案.md) | PPT 增强 + 海报新模块:数据库设计、API、前端、AI 生图方案(已确认) |
|
||||
| [PPT与海报功能开发任务清单.md](PPT与海报功能开发任务清单.md) | 120 项可勾选任务,分 7 个 Phase,预估 11.5 天 |
|
||||
| [baodanppt集成计划.md](baodanppt集成计划.md) | baodanppt 原始集成计划 |
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
<div class="step-preview">
|
||||
<h3>预览导出</h3>
|
||||
|
||||
<div v-if="!posterUrl" class="generate-section">
|
||||
<div v-if="!posterObjectUrl" class="generate-section">
|
||||
<el-select v-model="selectedSize" style="width: 200px; margin-bottom: 16px">
|
||||
<el-option label="竖版海报 1080x1920" value="1024x1792" />
|
||||
<el-option label="横版海报 900x500" value="1792x1024" />
|
||||
@ -17,12 +17,12 @@
|
||||
</div>
|
||||
|
||||
<div v-else class="result-section">
|
||||
<el-image :src="posterUrl" fit="contain" style="max-width: 100%; max-height: 500px; border-radius: 8px" />
|
||||
<el-image :src="posterObjectUrl" fit="contain" style="max-width: 100%; max-height: 500px; border-radius: 8px" />
|
||||
<div class="result-actions">
|
||||
<el-button type="primary" @click="onDownload">
|
||||
<el-icon><Download /></el-icon> 下载海报
|
||||
</el-button>
|
||||
<el-button @click="posterUrl = null; recordId = null">重新生成</el-button>
|
||||
<el-button @click="clearPoster">重新生成</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -33,7 +33,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, onBeforeUnmount } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Picture, Download } from '@element-plus/icons-vue'
|
||||
import { posterApi } from '@/utils/poster-api'
|
||||
@ -49,9 +49,17 @@ defineEmits<{ back: [] }>()
|
||||
|
||||
const selectedSize = ref('1024x1792')
|
||||
const generating = ref(false)
|
||||
const posterUrl = ref<string | null>(null)
|
||||
const posterObjectUrl = ref<string | null>(null)
|
||||
const recordId = ref<number | null>(null)
|
||||
|
||||
function clearPoster() {
|
||||
if (posterObjectUrl.value) {
|
||||
URL.revokeObjectURL(posterObjectUrl.value)
|
||||
}
|
||||
posterObjectUrl.value = null
|
||||
recordId.value = null
|
||||
}
|
||||
|
||||
async function onGenerate() {
|
||||
generating.value = true
|
||||
try {
|
||||
@ -66,8 +74,10 @@ async function onGenerate() {
|
||||
})
|
||||
const record = res?.data?.data ?? res?.data
|
||||
recordId.value = record?.id
|
||||
if (record?.exportUrl) {
|
||||
posterUrl.value = `/insurance/poster/download/${record.id}`
|
||||
if (record?.id) {
|
||||
// 通过鉴权接口获取图片 Blob
|
||||
const blob = await posterApi.downloadPoster(record.id)
|
||||
posterObjectUrl.value = URL.createObjectURL(blob)
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || '生成失败')
|
||||
@ -76,11 +86,26 @@ async function onGenerate() {
|
||||
}
|
||||
}
|
||||
|
||||
function onDownload() {
|
||||
async function onDownload() {
|
||||
if (!recordId.value) return
|
||||
const url = posterApi.getDownloadUrl(recordId.value)
|
||||
window.open(url, '_blank')
|
||||
try {
|
||||
const blob = await posterApi.downloadPoster(recordId.value)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `poster_${recordId.value}.png`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (e: any) {
|
||||
ElMessage.error('下载失败')
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (posterObjectUrl.value) {
|
||||
URL.revokeObjectURL(posterObjectUrl.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@ -15,6 +15,13 @@
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="generationMode" label="生成方式" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="row.generationMode === 'ai' ? 'primary' : 'warning'">
|
||||
{{ row.generationMode === 'ai' ? 'AI 生图' : '降级' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="exportSize" label="尺寸" width="100" />
|
||||
<el-table-column label="操作" width="180">
|
||||
<template #default="{ row }">
|
||||
@ -30,10 +37,11 @@
|
||||
|
||||
<el-dialog v-model="detailVisible" title="海报详情" width="600px">
|
||||
<div v-if="detailRecord">
|
||||
<el-image v-if="detailRecord.exportUrl" :src="`/insurance/poster/download/${detailRecord.id}`"
|
||||
fit="contain" style="width: 100%; max-height: 300px; margin-bottom: 16px" />
|
||||
<img v-if="detailPreviewUrl" :src="detailPreviewUrl"
|
||||
style="width: 100%; max-height: 300px; object-fit: contain; margin-bottom: 16px; border-radius: 8px" />
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="文案模式">{{ detailRecord.copyMode }}</el-descriptions-item>
|
||||
<el-descriptions-item label="生成方式">{{ detailRecord.generationMode === 'ai' ? 'AI 生图' : '降级方案' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="文案内容">
|
||||
<pre v-if="detailRecord.copyContent" style="font-size: 12px">{{ JSON.stringify(detailRecord.copyContent, null, 2) }}</pre>
|
||||
<span v-else>-</span>
|
||||
@ -47,7 +55,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { posterApi } from '@/utils/poster-api'
|
||||
|
||||
const list = ref<any[]>([])
|
||||
@ -58,6 +67,7 @@ const loading = ref(false)
|
||||
|
||||
const detailVisible = ref(false)
|
||||
const detailRecord = ref<any>(null)
|
||||
const detailPreviewUrl = ref<string | null>(null)
|
||||
|
||||
onMounted(() => loadData())
|
||||
|
||||
@ -65,22 +75,50 @@ async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await posterApi.getRecords({ page: page.value, page_size: pageSize })
|
||||
// Axios 拦截器已解包 response.data,res 即为 { code, data: { total, items } }
|
||||
const data = res?.data ?? res
|
||||
list.value = data?.data?.items ?? []
|
||||
total.value = data?.data?.total ?? 0
|
||||
list.value = data?.items ?? []
|
||||
total.value = data?.total ?? 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function showDetail(row: any) {
|
||||
async function showDetail(row: any) {
|
||||
detailRecord.value = row
|
||||
detailVisible.value = true
|
||||
// 通过鉴权接口加载预览图
|
||||
if (detailPreviewUrl.value) {
|
||||
URL.revokeObjectURL(detailPreviewUrl.value)
|
||||
detailPreviewUrl.value = null
|
||||
}
|
||||
try {
|
||||
const blob = await posterApi.downloadPoster(row.id)
|
||||
detailPreviewUrl.value = URL.createObjectURL(blob)
|
||||
} catch {
|
||||
detailPreviewUrl.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function onDownload(row: any) {
|
||||
window.open(posterApi.getDownloadUrl(row.id), '_blank')
|
||||
async function onDownload(row: any) {
|
||||
try {
|
||||
const blob = await posterApi.downloadPoster(row.id)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `poster_${row.id}.png`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (e: any) {
|
||||
ElMessage.error('下载失败')
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (detailPreviewUrl.value) {
|
||||
URL.revokeObjectURL(detailPreviewUrl.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@ -37,6 +37,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { SuccessFilled, Download } from '@element-plus/icons-vue'
|
||||
import { pptApi } from '@/utils/ppt-api'
|
||||
|
||||
@ -81,8 +82,7 @@ async function downloadPpt() {
|
||||
window.URL.revokeObjectURL(url)
|
||||
} catch (error) {
|
||||
console.error('下载失败:', error)
|
||||
const url = pptApi.getDownloadUrl(props.sessionId)
|
||||
window.open(url, '_blank')
|
||||
ElMessage.error('PPT 下载失败,请重试')
|
||||
} finally {
|
||||
setTimeout(() => { downloading.value = false }, 500)
|
||||
}
|
||||
|
||||
@ -49,9 +49,9 @@ export const posterApi = {
|
||||
return api.post('/poster/generate', params)
|
||||
},
|
||||
|
||||
/** 下载海报 */
|
||||
getDownloadUrl(recordId: number) {
|
||||
return `/insurance/poster/download/${recordId}`
|
||||
/** 下载海报(鉴权 Blob,用于预览和下载) */
|
||||
downloadPoster(recordId: number): Promise<Blob> {
|
||||
return api.get(`/poster/download/${recordId}`, { responseType: 'blob' })
|
||||
},
|
||||
|
||||
/** 海报记录列表 */
|
||||
|
||||
Loading…
Reference in New Issue
Block a user