From 67b2dac23f90356e960c2c855f95874b766b4829 Mon Sep 17 00:00:00 2001 From: wsb1224 Date: Mon, 27 Jul 2026 13:21:34 +0800 Subject: [PATCH] =?UTF-8?q?slidesConfig=20=E7=BB=93=E6=9E=84=E9=94=99?= =?UTF-8?q?=E8=AF=AF=20=E2=80=94=20migrate=5F017=20=E8=BE=93=E5=87=BA?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E6=95=B0=E7=BB=84=E6=A0=BC=E5=BC=8F=EF=BC=8C?= =?UTF-8?q?migrate=5F019=20=E4=BF=AE=E5=A4=8D=E5=8E=86=E5=8F=B2=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=20=E8=BF=81=E7=A7=BB=E7=B3=BB=E7=BB=9F=E5=8A=A0?= =?UTF-8?q?=E5=9B=BA=20=E2=80=94=20=E5=8A=A0=20advisory=20lock=20=E9=98=B2?= =?UTF-8?q?=E5=B9=B6=E5=8F=91=EF=BC=8C=E5=A4=B1=E8=B4=A5=E5=9B=9E=E6=BB=9A?= =?UTF-8?q?=E5=B9=B6=E4=B8=AD=E6=AD=A2=E5=90=AF=E5=8A=A8=EF=BC=88=E5=8E=9F?= =?UTF-8?q?=E6=9D=A5=E6=98=AF=20catch-and-continue=EF=BC=89=20=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E6=BC=8F=E6=B4=9E=20=E2=80=94=20Settings/History=20AP?= =?UTF-8?q?I=20=E6=9D=83=E9=99=90=E6=94=B6=E7=B4=A7=EF=BC=8CAPI=20key=20?= =?UTF-8?q?=E8=BF=94=E5=9B=9E=E6=8E=A9=E7=A0=81=E5=80=BC=EF=BC=8C=E6=B5=B7?= =?UTF-8?q?=E6=8A=A5=20case=20=E6=89=80=E6=9C=89=E6=9D=83=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=20=E4=BE=9D=E8=B5=96=E7=BC=BA=E5=A4=B1=20=E2=80=94=20?= =?UTF-8?q?requirements.txt=20=E8=A1=A5=E9=BD=90=20python-pptx/openai/Pill?= =?UTF-8?q?ow=EF=BC=8CDockerfile=20=E6=94=B9=E4=B8=BA=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E5=AE=89=E8=A3=85=20=E6=B5=B7=E6=8A=A5=E9=89=B4=E6=9D=83?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD=20=E2=80=94=20=E5=89=8D=E7=AB=AF=E5=85=A8?= =?UTF-8?q?=E9=83=A8=E6=94=B9=E7=94=A8=20authenticated=20blob=EF=BC=8C?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=20window.open=20=E6=97=A0=20token=20URL=20LL?= =?UTF-8?q?M=20=E9=85=8D=E7=BD=AE=E5=88=86=E7=A6=BB=20=E2=80=94=20?= =?UTF-8?q?=E6=B5=B7=E6=8A=A5=E6=96=87=E6=A1=88=E8=AF=BB=E5=8F=96=20poster?= =?UTF-8?q?=5Fllm=5F*=EF=BC=88=E4=B8=8D=E5=86=8D=E5=A4=8D=E7=94=A8=20ppt?= =?UTF-8?q?=5Fllm=5F*=EF=BC=89=EF=BC=8C=E6=94=AF=E6=8C=81=20config=20names?= =?UTF-8?q?pace=20=E5=9B=BE=E7=89=87=E7=94=9F=E6=88=90=E5=99=A8=20?= =?UTF-8?q?=E2=80=94=20=E5=85=BC=E5=AE=B9=20b64=5Fjson=20=E5=92=8C=20URL?= =?UTF-8?q?=20=E4=B8=A4=E7=A7=8D=E5=93=8D=E5=BA=94=E6=A0=BC=E5=BC=8F?= =?UTF-8?q?=EF=BC=8C=E8=BF=BD=E8=B8=AA=20generation=5Fmode/provider/model?= =?UTF-8?q?=20=E7=A7=8D=E5=AD=90=E6=95=B0=E6=8D=AE=20=E2=80=94=20=E6=96=B0?= =?UTF-8?q?=E7=8E=AF=E5=A2=83=E8=87=AA=E5=8A=A8=E8=8E=B7=E5=BE=97=202=20?= =?UTF-8?q?=E4=B8=AA=E6=B5=B7=E6=8A=A5=E6=A8=A1=E6=9D=BF=20+=202=20?= =?UTF-8?q?=E4=B8=AA=E6=96=87=E6=A1=88=E6=A8=A1=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/insurance/admin/ppt_admin_routes.py | 10 +- api/insurance/admin/ppt_admin_service.py | 37 ++++- api/insurance/db/__init__.py | 139 +++++++++++------- api/insurance/db/migrate_017.py | 2 +- api/insurance/models/poster_record.py | 6 + api/insurance/poster/copy_generator.py | 4 +- api/insurance/poster/image_generator.py | 56 ++++--- api/insurance/poster/service.py | 16 +- api/insurance/ppt/llm_client.py | 45 +++--- api/insurance/requirements.txt | 7 + docs/README.md | 3 +- .../components/poster/PosterStepPreview.vue | 45 ++++-- frontend/src/pages/PosterHistoryPage.vue | 54 ++++++- .../src/pages/components/ppt/PptResult.vue | 4 +- frontend/src/utils/poster-api.ts | 6 +- 15 files changed, 314 insertions(+), 120 deletions(-) diff --git a/api/insurance/admin/ppt_admin_routes.py b/api/insurance/admin/ppt_admin_routes.py index 925dcc5..da0e53d 100644 --- a/api/insurance/admin/ppt_admin_routes.py +++ b/api/insurance/admin/ppt_admin_routes.py @@ -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()) diff --git a/api/insurance/admin/ppt_admin_service.py b/api/insurance/admin/ppt_admin_service.py index 1ac52e4..ef1274c 100644 --- a/api/insurance/admin/ppt_admin_service.py +++ b/api/insurance/admin/ppt_admin_service.py @@ -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} diff --git a/api/insurance/db/__init__.py b/api/insurance/db/__init__.py index 6a758ab..52b1aea 100644 --- a/api/insurance/db/__init__.py +++ b/api/insurance/db/__init__.py @@ -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(): diff --git a/api/insurance/db/migrate_017.py b/api/insurance/db/migrate_017.py index 661e674..a52adcd 100644 --- a/api/insurance/db/migrate_017.py +++ b/api/insurance/db/migrate_017.py @@ -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): diff --git a/api/insurance/models/poster_record.py b/api/insurance/models/poster_record.py index 631c44e..46cafde 100644 --- a/api/insurance/models/poster_record.py +++ b/api/insurance/models/poster_record.py @@ -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, } diff --git a/api/insurance/poster/copy_generator.py b/api/insurance/poster/copy_generator.py index 46f9f5a..f1ea37e 100644 --- a/api/insurance/poster/copy_generator.py +++ b/api/insurance/poster/copy_generator.py @@ -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", diff --git a/api/insurance/poster/image_generator.py b/api/insurance/poster/image_generator.py index 02ea120..43bf6dc 100644 --- a/api/insurance/poster/image_generator.py +++ b/api/insurance/poster/image_generator.py @@ -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: diff --git a/api/insurance/poster/service.py b/api/insurance/poster/service.py index 0513054..36cc56c 100644 --- a/api/insurance/poster/service.py +++ b/api/insurance/poster/service.py @@ -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() diff --git a/api/insurance/ppt/llm_client.py b/api/insurance/ppt/llm_client.py index be53681..1b27ac7 100644 --- a/api/insurance/ppt/llm_client.py +++ b/api/insurance/ppt/llm_client.py @@ -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") diff --git a/api/insurance/requirements.txt b/api/insurance/requirements.txt index 0e54369..4593275 100644 --- a/api/insurance/requirements.txt +++ b/api/insurance/requirements.txt @@ -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 diff --git a/docs/README.md b/docs/README.md index 13eda58..c3666a9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 原始集成计划 | diff --git a/frontend/src/components/poster/PosterStepPreview.vue b/frontend/src/components/poster/PosterStepPreview.vue index 05a85b2..daeea9a 100644 --- a/frontend/src/components/poster/PosterStepPreview.vue +++ b/frontend/src/components/poster/PosterStepPreview.vue @@ -2,7 +2,7 @@

预览导出

-
+
@@ -17,12 +17,12 @@
- +
下载海报 - 重新生成 + 重新生成
@@ -33,7 +33,7 @@