diff --git a/api/insurance/admin/ppt_admin_service.py b/api/insurance/admin/ppt_admin_service.py index ebddeee..249f338 100644 --- a/api/insurance/admin/ppt_admin_service.py +++ b/api/insurance/admin/ppt_admin_service.py @@ -660,6 +660,7 @@ class PptAdminService: delete_stored_template, parse_template_pptx, save_uploaded_template, + template_asset_sha256, ) resolved_id = template_id or str(data.get("id") or "").strip() @@ -676,11 +677,13 @@ class PptAdminService: try: new_asset_id, file_path = save_uploaded_template(file, resolved_id) parsed = parse_template_pptx(file_path) + asset_sha256 = template_asset_sha256(file_path) except ValueError as exc: delete_stored_template(new_asset_id) return {"code": 1001, "message": str(exc), "data": None} old_asset_id = template.source_template_asset_id if template else None + old_asset_version = int(template.asset_version or 0) if template else 0 if template is None: template = PptTemplate(id=resolved_id) db.session.add(template) @@ -690,8 +693,10 @@ class PptAdminService: template.name = data.get("name") or template.name or file.filename template.scenario_tag = data.get("scenarioTag") or template.scenario_tag template.source_template_asset_id = new_asset_id + template.asset_sha256 = asset_sha256 + template.asset_version = old_asset_version + 1 template.clone_ready = True - template.clone_renderer = "python-pptx-theme-v1" + template.clone_renderer = "clone-edit-v2" template.required_page_types_json = json.dumps( parsed["requiredPageTypes"], ensure_ascii=False ) @@ -705,7 +710,8 @@ class PptAdminService: db.session.rollback() delete_stored_template(new_asset_id) raise - delete_stored_template(old_asset_id) + # 旧资产可能仍被排队任务的不可变快照引用,不能在切换当前版本时删除。 + # 物理清理由保留期任务统一处理。 result = template.to_dict() result["slideCount"] = parsed["slideCount"] @@ -713,6 +719,7 @@ class PptAdminService: "width": parsed["width"], "height": parsed["height"], } + result["previousAssetId"] = old_asset_id return {"code": 0, "data": result} def update_template(self, template_id: str, data: dict) -> dict: diff --git a/api/insurance/db/migrate_024.py b/api/insurance/db/migrate_024.py index 03be93d..5b0b515 100644 --- a/api/insurance/db/migrate_024.py +++ b/api/insurance/db/migrate_024.py @@ -52,7 +52,7 @@ def migrate(): ).first() values = { **seed, - "clone_renderer": "python-pptx-theme-v1", + "clone_renderer": "clone-edit-v2", "required_page_types_json": json.dumps( parsed["requiredPageTypes"], ensure_ascii=False ), diff --git a/api/insurance/db/migrate_029.py b/api/insurance/db/migrate_029.py new file mode 100644 index 0000000..2411f9e --- /dev/null +++ b/api/insurance/db/migrate_029.py @@ -0,0 +1,68 @@ +"""迁移 029:扩容 PPT 模板资产标识并增加版本指纹。""" +import logging +import os + +from sqlalchemy import inspect, text + +logger = logging.getLogger(__name__) + + +def migrate(): + from insurance.db.compat import db + + columns = { + item["name"]: item + for item in inspect(db.engine).get_columns("insurance_ppt_templates") + } + dialect = db.engine.dialect.name + + if dialect == "postgresql": + db.session.execute(text( + "ALTER TABLE insurance_ppt_templates " + "ALTER COLUMN source_template_asset_id TYPE VARCHAR(255)" + )) + elif dialect in {"mysql", "mariadb"}: + db.session.execute(text( + "ALTER TABLE insurance_ppt_templates " + "MODIFY source_template_asset_id VARCHAR(255)" + )) + + if "asset_sha256" not in columns: + db.session.execute(text( + "ALTER TABLE insurance_ppt_templates ADD COLUMN asset_sha256 VARCHAR(64)" + )) + if "asset_version" not in columns: + db.session.execute(text( + "ALTER TABLE insurance_ppt_templates " + "ADD COLUMN asset_version INTEGER NOT NULL DEFAULT 1" + )) + + from insurance.ppt.template_asset_service import ( + resolve_template_asset, + template_asset_sha256, + ) + + rows = db.session.execute(text( + "SELECT id, source_template_asset_id FROM insurance_ppt_templates " + "WHERE source_template_asset_id IS NOT NULL" + )).mappings().all() + for row in rows: + try: + path = resolve_template_asset(row["source_template_asset_id"]) + digest = template_asset_sha256(path) if path and os.path.isfile(path) else None + except (OSError, ValueError): + digest = None + if digest: + db.session.execute(text( + "UPDATE insurance_ppt_templates " + "SET asset_sha256 = :digest, asset_version = COALESCE(asset_version, 1) " + "WHERE id = :id" + ), {"digest": digest, "id": row["id"]}) + + db.session.execute(text( + "UPDATE insurance_ppt_templates SET clone_renderer = 'clone-edit-v2' " + "WHERE source_template_asset_id IS NOT NULL" + )) + + db.session.commit() + logger.info("[migrate_029] PPT 模板资产字段与指纹迁移完成") diff --git a/api/insurance/db/migrate_030.py b/api/insurance/db/migrate_030.py new file mode 100644 index 0000000..5abf21b --- /dev/null +++ b/api/insurance/db/migrate_030.py @@ -0,0 +1,15 @@ +"""统一真实模板渲染模式标识。""" + +from sqlalchemy import text + +from insurance.db.compat import db + + +def migrate(): + """将过渡期名称统一为对外约定的 clone-edit-v2。""" + db.session.execute(text( + "UPDATE insurance_ppt_templates " + "SET clone_renderer = 'clone-edit-v2' " + "WHERE clone_renderer = 'frame-clone-v2'" + )) + db.session.commit() diff --git a/api/insurance/generation/celery_tasks.py b/api/insurance/generation/celery_tasks.py index ddef70b..9ed6d2b 100644 --- a/api/insurance/generation/celery_tasks.py +++ b/api/insurance/generation/celery_tasks.py @@ -320,6 +320,7 @@ def _execute_ppt_generate(task_id: str): snapshot = json.loads(task.input_snapshot_json) if task.input_snapshot_json else {} theme = snapshot.get("theme", "broker") template_id = snapshot.get("templateId", "") + template_asset_snapshot = snapshot.get("templateAsset") or {} company_id = snapshot.get("companyId", "") brand_policy = snapshot.get("brandPolicy") or {} requested_scenario = snapshot.get("scenario", "") @@ -452,9 +453,38 @@ def _execute_ppt_generate(task_id: str): ).first() template_config = template.to_dict() if template else None if template_config and template.source_template_asset_id: - from insurance.ppt.template_asset_service import resolve_template_asset - template_config["sourceTemplatePath"] = resolve_template_asset( - template.source_template_asset_id + from insurance.ppt.template_asset_service import ( + resolve_template_asset, + template_asset_sha256, + ) + asset_id = template_asset_snapshot.get("assetId") or template.source_template_asset_id + source_template_path = resolve_template_asset(asset_id) + if not source_template_path or not os.path.isfile(source_template_path): + _update_task_status( + task_id, status="failed", error_code="template_asset_missing", + error_message="生成任务引用的模板文件不存在,请重新提交", + finished_at=datetime.now(), + ) + return + actual_sha256 = template_asset_sha256(source_template_path) + expected_sha256 = template_asset_snapshot.get("sha256") + if expected_sha256 and actual_sha256 != expected_sha256: + _update_task_status( + task_id, status="failed", error_code="template_asset_changed", + error_message="模板文件版本校验失败,请重新提交生成任务", + finished_at=datetime.now(), + ) + return + template_config["sourceTemplateAssetId"] = asset_id + template_config["sourceTemplatePath"] = source_template_path + template_config["assetSha256"] = actual_sha256 + template_config["assetVersion"] = ( + template_asset_snapshot.get("version") or template.asset_version or 1 + ) + template_config["cloneRenderer"] = ( + template_asset_snapshot.get("rendererMode") + or template.clone_renderer + or "clone-edit-v2" ) company_info = None @@ -576,6 +606,12 @@ def _execute_ppt_generate(task_id: str): "templateId": template.id if template else None, "templateName": template.name if template else None, "stylePreset": template.style_preset if template else theme, + "templateAsset": ({ + "assetId": template_config.get("sourceTemplateAssetId"), + "version": template_config.get("assetVersion"), + "sha256": template_config.get("assetSha256"), + "rendererMode": result.get("rendererMode", "generic-builder-v1"), + } if template_config and template_config.get("sourceTemplateAssetId") else None), "createdAt": datetime.now().isoformat(), }) session.versions_json = json.dumps(versions, ensure_ascii=False) @@ -614,6 +650,11 @@ def _execute_ppt_generate(task_id: str): "appliedTemplateId": template.id if template else None, "appliedTemplateName": template.name if template else None, "appliedStylePreset": template.style_preset if template else theme, + "templateAssetId": template_config.get("sourceTemplateAssetId") if template_config else None, + "templateAssetVersion": template_config.get("assetVersion") if template_config else None, + "templateAssetSha256": template_config.get("assetSha256") if template_config else None, + "rendererMode": result.get("rendererMode", "generic-builder-v1"), + "templateFrameMap": result.get("templateFrameMap", []), "templateFallback": False, "revision": session.generated_revision if session else 0, }, ensure_ascii=False), diff --git a/api/insurance/models/ppt_config.py b/api/insurance/models/ppt_config.py index 0e57063..f6f1179 100644 --- a/api/insurance/models/ppt_config.py +++ b/api/insurance/models/ppt_config.py @@ -141,7 +141,9 @@ class PptTemplate(db.Model): id = Column(String(50), primary_key=True, comment="模板 ID") plan_type = Column(String(10), nullable=False, comment="产品类型: savings/ci/iul") style_preset = Column(String(20), nullable=False, comment="风格: broker/business/minimal/chinese/ink") - source_template_asset_id = Column(String(50), nullable=True, comment="源模板资产 ID") + source_template_asset_id = Column(String(255), nullable=True, comment="源模板资产 ID") + asset_sha256 = Column(String(64), nullable=True, comment="当前模板资产 SHA-256") + asset_version = Column(Integer, default=1, comment="当前模板资产版本") clone_ready = Column(Boolean, default=False, comment="是否克隆就绪") clone_renderer = Column(String(100), nullable=True, comment="克隆渲染器 ID") required_page_types_json = Column(Text, nullable=True, comment="必需页面类型 JSON") @@ -179,6 +181,8 @@ class PptTemplate(db.Model): "planType": self.plan_type, "stylePreset": self.style_preset, "sourceTemplateAssetId": self.source_template_asset_id, + "assetSha256": self.asset_sha256, + "assetVersion": self.asset_version or 1, "sourceFileUrl": ( f"/insurance/admin/ppt/templates/{self.id}/file" if self.source_template_asset_id else "" diff --git a/api/insurance/poster/service.py b/api/insurance/poster/service.py index 7fa16fa..5ae18d5 100644 --- a/api/insurance/poster/service.py +++ b/api/insurance/poster/service.py @@ -6,6 +6,7 @@ import os import re import uuid import logging +from datetime import datetime, timedelta from flask import current_app from insurance.db.compat import db from insurance.models.ppt_config import PptProduct, PptCompany @@ -299,14 +300,24 @@ class PosterService: if case.confirmed_data is None: return {"code": 1002, "message": "请先确认解析数据", "data": None} - # 幂等检查(TASK-P1-02):相同参数的未完成任务直接返回 - if not force_regenerate and case_upload_id: - existing = PosterRecord.query.filter( + # 幂等检查(TASK-P1-02):拦截同一页面的连续重复提交。 + if not force_regenerate: + active_query = PosterRecord.query.filter( PosterRecord.user_id == user_id, - PosterRecord.case_upload_id == case_upload_id, PosterRecord.template_id == template_id, PosterRecord.task_status.in_(["pending", "queued", "generating"]), - ).order_by(PosterRecord.created_at.desc()).first() + PosterRecord.created_at >= datetime.now() - timedelta(seconds=15), + ) + if case_upload_id: + active_query = active_query.filter( + PosterRecord.case_upload_id == case_upload_id, + ) + else: + active_query = active_query.filter( + PosterRecord.product_source_type == context["sourceType"], + PosterRecord.product_source_id == context["sourceId"], + ) + existing = active_query.order_by(PosterRecord.created_at.desc()).first() if existing: return {"code": 0, "data": existing.to_dict()} diff --git a/api/insurance/ppt/renderer.py b/api/insurance/ppt/renderer.py index f0a1464..b47965b 100644 --- a/api/insurance/ppt/renderer.py +++ b/api/insurance/ppt/renderer.py @@ -61,6 +61,7 @@ def _build_deck_contract( "business": "business", "minimal": "minimal", "ink": "ink", + "sage": "sage", } def _extract_product(data): @@ -219,6 +220,7 @@ class PptRenderer: "business": "business", "minimal": "minimal", "ink": "ink", + "sage": "sage", } script_theme = theme_map.get(theme, "deepblue") @@ -240,6 +242,8 @@ class PptRenderer: "ok": True, "path": output_path, "slideCount": script_result.get("slides", 0), + "rendererMode": script_result.get("rendererMode", "generic-builder-v1"), + "templateFrameMap": script_result.get("templateFrameMap", []), "deck": deck, } else: diff --git a/api/insurance/ppt/routes.py b/api/insurance/ppt/routes.py index cd3e327..65c4cae 100644 --- a/api/insurance/ppt/routes.py +++ b/api/insurance/ppt/routes.py @@ -462,6 +462,7 @@ def generate_ppt(session_id): ) template_data = None + template_asset_snapshot = None if template_id: from insurance.models.ppt_config import PptTemplate from insurance.ppt.comparison import detect_generation_scenario @@ -471,6 +472,27 @@ def generate_ppt(session_id): if not template: return error(ErrorCode.PARAM_ERROR, "所选 PPT 模板不存在或已停用") template_data = template.to_dict() + if template.source_template_asset_id: + from insurance.ppt.template_asset_service import ( + resolve_template_asset, + template_asset_sha256, + ) + try: + asset_path = resolve_template_asset(template.source_template_asset_id) + asset_sha256 = template.asset_sha256 or ( + template_asset_sha256(asset_path) if asset_path and os.path.isfile(asset_path) else None + ) + except (OSError, ValueError): + asset_path = None + asset_sha256 = None + if not asset_path or not asset_sha256: + return error(ErrorCode.PARAM_ERROR, "所选 PPT 模板文件不存在或校验失败") + template_asset_snapshot = { + "assetId": template.source_template_asset_id, + "version": template.asset_version or 1, + "sha256": asset_sha256, + "rendererMode": template.clone_renderer or "clone-edit-v2", + } file_kinds = [ {"kind": item.get("type")} for item in files @@ -546,6 +568,7 @@ def generate_ppt(session_id): "productIds": product_ids, "brandPolicy": brand_policy, "scenario": scenario, + "templateAsset": template_asset_snapshot, }, input_revision=session.draft_revision or 1, idempotency_key=f"ppt_gen_{session_id}_{session.draft_revision}", diff --git a/api/insurance/ppt/scripts/fast_pptx_renderer.py b/api/insurance/ppt/scripts/fast_pptx_renderer.py index e4f950f..1b54135 100644 --- a/api/insurance/ppt/scripts/fast_pptx_renderer.py +++ b/api/insurance/ppt/scripts/fast_pptx_renderer.py @@ -30,7 +30,7 @@ from pptx import Presentation from pptx.chart.data import CategoryChartData from pptx.dml.color import RGBColor from pptx.enum.chart import XL_CHART_TYPE, XL_LEGEND_POSITION -from pptx.enum.shapes import MSO_AUTO_SHAPE_TYPE +from pptx.enum.shapes import MSO_AUTO_SHAPE_TYPE, MSO_SHAPE_TYPE from pptx.enum.text import MSO_ANCHOR, PP_ALIGN from pptx.util import Inches, Pt @@ -122,6 +122,20 @@ THEMES = { "good_light": RGBColor(236, 252, 203), "alert": RGBColor(153, 27, 27), }, + "sage": { + "bg": RGBColor(248, 246, 239), + "panel": RGBColor(255, 255, 255), + "primary": RGBColor(31, 82, 67), + "text_dark": RGBColor(34, 63, 55), + "accent": RGBColor(47, 112, 91), + "accent_light": RGBColor(224, 239, 232), + "gold": RGBColor(204, 151, 57), + "muted": RGBColor(92, 112, 104), + "line": RGBColor(218, 224, 215), + "good": RGBColor(47, 112, 91), + "good_light": RGBColor(211, 233, 221), + "alert": RGBColor(173, 68, 52), + }, } FONT_CN = "Microsoft YaHei" @@ -212,6 +226,9 @@ def add_bg(slide, colors): def add_blank_slide(prs): """兼容只有少量自定义版式的源 PPTX。""" + template_queue = getattr(prs, "_insurance_template_slide_queue", None) + if template_queue: + return template_queue.pop(0) layouts = list(prs.slide_layouts) if not layouts: raise ValueError("源模板没有可用幻灯片版式") @@ -1634,6 +1651,156 @@ DEFAULT_PAGE_TYPES = { } +_BUILTIN_FRAME_MAPS = { + # 这些映射只定义“当前场景页使用哪一张源模板页作为可编辑底稿”。 + # 模板中的示例文本、图表和表格会在绘制真实数据前清空。 + "single_savings.pptx": { + 12: [1, 2, 3, 6, 8, 9, 10, 11, 18, 13, 17, 20], + 14: [1, 2, 3, 6, 8, 9, 10, 11, 18, 14, 19, 13, 17, 20], + }, + "multi_savings_comparison.pptx": { + 10: [1, 2, 3, 4, 15, 16, 11, 13, 14, 17], + }, + "savings_iul_comprehensive.pptx": { + 15: [1, 2, 3, 4, 8, 9, 11, 12, 13, 14, 17, 18, 6, 16, 15], + }, +} + +_TEMPLATE_THEME_BY_ASSET = { + "single_savings.pptx": "caramel", + "multi_savings_comparison.pptx": "caramel", + "savings_iul_comprehensive.pptx": "sage", +} + +_PAGE_TYPE_ALIASES = { + "comparison_chart": {"chart"}, + "comparison_table": {"compare", "policy_summary", "table"}, + "table": {"policy_summary", "chart"}, + "launch_paths": {"guidance", "timeline"}, + "alignment_table": {"combined_summary", "policy_summary", "closing"}, + "conclusion": {"guidance", "synergy", "closing"}, +} + + +def _auto_frame_map(page_types: list[str], template_config: dict, slide_count: int) -> list[int]: + """为上传模板建立确定性的逐页映射;页面不足时明确失败。""" + if slide_count < len(page_types): + raise ValueError( + f"所选模板只有 {slide_count} 页,当前方案需要 {len(page_types)} 页," + "请上传页数充足的模板或调整模板页面配置" + ) + + source_slides = template_config.get("slidesConfig") or [] + source_types = [str(item.get("pageType") or "") for item in source_slides] + if len(source_types) < slide_count: + source_types.extend([""] * (slide_count - len(source_types))) + + available = set(range(slide_count)) + selected = [] + for page_type in page_types: + candidates = [ + index for index in sorted(available) + if source_types[index] == page_type + ] + if not candidates: + aliases = _PAGE_TYPE_ALIASES.get(page_type, set()) + candidates = [ + index for index in sorted(available) + if source_types[index] in aliases + ] + index = candidates[0] if candidates else min(available) + available.remove(index) + selected.append(index + 1) + return selected + + +def _resolve_template_frame_map( + source_template_path: str, + page_types: list[str], + template_config: dict, + slide_count: int, +) -> list[int]: + configured = template_config.get("frameMap") + if configured: + frame_map = [int(value) for value in configured] + if len(frame_map) != len(page_types): + raise ValueError("模板 frameMap 页数与当前方案页数不一致") + if any(value < 1 or value > slide_count for value in frame_map): + raise ValueError("模板 frameMap 包含无效源页码") + if len(set(frame_map)) != len(frame_map): + raise ValueError("模板 frameMap 不允许重复使用同一源页面") + return frame_map + + asset_name = os.path.basename(source_template_path).lower() + builtin = _BUILTIN_FRAME_MAPS.get(asset_name, {}).get(len(page_types)) + if builtin: + return list(builtin) + return _auto_frame_map(page_types, template_config, slide_count) + + +def _remove_shape(shape): + element = shape._element + element.getparent().remove(element) + + +def _group_contains_picture(shape) -> bool: + for child in shape.shapes: + if child.shape_type == MSO_SHAPE_TYPE.PICTURE: + return True + if child.shape_type == MSO_SHAPE_TYPE.GROUP and _group_contains_picture(child): + return True + return False + + +def _sanitize_template_shape(shape, slide_width: int, slide_height: int): + """移除示例数据,保留背景、图片和形状几何作为模板底稿。""" + if shape.shape_type == MSO_SHAPE_TYPE.GROUP: + if not _group_contains_picture(shape): + _remove_shape(shape) + return + for child in list(shape.shapes): + _sanitize_template_shape(child, slide_width, slide_height) + return + if getattr(shape, "has_chart", False) or getattr(shape, "has_table", False): + _remove_shape(shape) + return + if getattr(shape, "has_text_frame", False) and shape.text.strip(): + _remove_shape(shape) + return + if shape.shape_type == MSO_SHAPE_TYPE.AUTO_SHAPE: + is_edge_or_background = ( + shape.width >= slide_width * 0.9 + or shape.height >= slide_height * 0.9 + or shape.width <= slide_width * 0.02 + or shape.height <= slide_height * 0.02 + ) + if not is_edge_or_background: + _remove_shape(shape) + + +def _prepare_template_frames(prs, frame_map: list[int]): + """保留并重排选中的源页面,随后让现有 builder 原位写入真实数据。""" + slide_ids = list(prs.slides._sldIdLst) + selected_ids = [slide_ids[index - 1] for index in frame_map] + selected_identity = {id(item) for item in selected_ids} + + for slide_id in list(slide_ids): + if id(slide_id) in selected_identity: + continue + prs.part.drop_rel(slide_id.rId) + prs.slides._sldIdLst.remove(slide_id) + + for slide_id in selected_ids: + prs.slides._sldIdLst.remove(slide_id) + prs.slides._sldIdLst.append(slide_id) + + slides = list(prs.slides) + for slide in slides: + for shape in list(slide.shapes): + _sanitize_template_shape(shape, prs.slide_width, prs.slide_height) + prs._insurance_template_slide_queue = slides + + def _resolve_page_types(deck): """从 DeckContract 中解析要渲染的页面类型列表。""" scenario_slides = deck.get("scenarioSlides", []) @@ -1659,25 +1826,32 @@ def _resolve_page_types(deck): def render_deck(deck: dict, output_path: str, theme: str = "deepblue") -> dict: """渲染 DeckContract 为 PPTX。""" - colors = THEMES.get(theme, THEMES["deepblue"]) - - source_template_path = ( - deck.get("templateConfig", {}).get("sourceTemplatePath") - ) - if source_template_path and os.path.isfile(source_template_path): - prs = Presentation(source_template_path) - while len(prs.slides): - slide_id = prs.slides._sldIdLst[0] - prs.part.drop_rel(slide_id.rId) - del prs.slides._sldIdLst[0] - else: - prs = Presentation() - prs.slide_width = Inches(13.33) - prs.slide_height = Inches(7.5) - template_config = deck.get("templateConfig", {}) slides_config = deck.get("scenarioSlides") or template_config.get("slidesConfig", []) page_types = _resolve_page_types(deck) + source_template_path = ( + template_config.get("sourceTemplatePath") + ) + renderer_mode = "generic-builder-v1" + if source_template_path and os.path.isfile(source_template_path): + prs = Presentation(source_template_path) + frame_map = _resolve_template_frame_map( + source_template_path, + page_types, + template_config, + len(prs.slides), + ) + _prepare_template_frames(prs, frame_map) + renderer_mode = "clone-edit-v2" + asset_name = os.path.basename(source_template_path).lower() + theme = template_config.get("renderTheme") or _TEMPLATE_THEME_BY_ASSET.get( + asset_name, theme + ) + else: + prs = Presentation() + prs.slide_width = Inches(13.33) + prs.slide_height = Inches(7.5) + colors = THEMES.get(theme, THEMES["deepblue"]) slide_errors = [] for i, page_type in enumerate(page_types): @@ -1705,6 +1879,8 @@ def render_deck(deck: dict, output_path: str, theme: str = "deepblue") -> dict: "path": output_path, "size": file_size, "slides": slide_count, + "rendererMode": renderer_mode, + "templateFrameMap": frame_map if renderer_mode == "clone-edit-v2" else [], } @@ -1715,7 +1891,7 @@ def main(): parser.add_argument("--deck-json", required=True, help="DeckContract JSON 文件路径") parser.add_argument("--output", required=True, help="输出 PPTX 文件路径") parser.add_argument("--theme", default="deepblue", - choices=["deepblue", "caramel", "chinese", "business", "minimal", "ink"], + choices=["deepblue", "caramel", "chinese", "business", "minimal", "ink", "sage"], help="主题配色") args = parser.parse_args() diff --git a/api/insurance/ppt/template_asset_service.py b/api/insurance/ppt/template_asset_service.py index e355db9..253e009 100644 --- a/api/insurance/ppt/template_asset_service.py +++ b/api/insurance/ppt/template_asset_service.py @@ -4,6 +4,7 @@ from __future__ import annotations import os import re import uuid +import hashlib BUILTIN_SCHEME = "builtin://" @@ -12,6 +13,15 @@ MAX_TEMPLATE_BYTES = 30 * 1024 * 1024 BUILTIN_TEMPLATE_ROOT = os.path.join(os.path.dirname(__file__), "template_assets") +def template_asset_sha256(path: str) -> str: + """计算模板资产指纹,供任务快照和 Worker 二次校验。""" + digest = hashlib.sha256() + with open(path, "rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + def resolve_template_asset(asset_id: str | None) -> str | None: """将数据库中的模板资产标识解析为受控的本地绝对路径。""" value = str(asset_id or "") diff --git a/docs/保险智能客服系统_PPT与海报优化修复计划书_20260731.md b/docs/保险智能客服系统_PPT与海报优化修复计划书_20260731.md index a3ba60d..f4a329f 100644 --- a/docs/保险智能客服系统_PPT与海报优化修复计划书_20260731.md +++ b/docs/保险智能客服系统_PPT与海报优化修复计划书_20260731.md @@ -1261,3 +1261,23 @@ slide_count, manifest_json, validation_status, created_at "templateFallback": false } ``` + +## 23. 专项修复实施记录(2026-07-31) + +本轮已完成以下修复: + +- 三个内置模板改为 `clone-edit-v2`:按模板清单选择原始页面并保留背景、图片和版式,不再清空源幻灯片后生成统一深蓝基础模板。 +- 源模板中的样例业务文本、表格和图表会在动态内容写入前清理;模板页数不足或页面映射非法时明确失败,不再静默回退。 +- 模板上传后记录 `assetVersion` 与 `assetSha256`;创建任务时固化资产 ID、版本、SHA-256 和渲染模式,Worker 严格消费该快照并复核文件哈希。 +- `source_template_asset_id` 已扩容为 `VARCHAR(255)`;旧模板文件在仍可能被排队任务引用时不再立即物理删除。 +- 海报工作区改为真实缩放画布并从顶部开始滚动;长图不再因垂直居中而无法查看顶部。 +- 海报完成状态增加资源加载阶段:背景、图片和字体全部可用且最终合成保存成功后,任务才显示完成;加载失败会展示可重试错误。 +- 前端生成按钮与后端 15 秒活动任务复用共同拦截快速重复提交,避免一次点击创建多条生成记录。 + +已完成验证: + +- 三个内置模板均使用真实历史生成契约渲染,输出模式均为 `clone-edit-v2`,且页面映射与模板分别对应。 +- PowerPoint 可直接打开三个生成文件,无修复提示;源模板的样例金额未出现在成品文本中。 +- IUL 模板的源图片资产在成品中保留;三套模板的最终视觉不再相同。 +- 迁移 `migrate_029`、`migrate_030` 已在当前 BaoDan 数据库实际执行,模板资产字段、版本、SHA-256 和 `clone-edit-v2` 模式标识已回填。 +- Python 编译、Vue 生产构建及 PPT/海报相关自动化回归均通过。 diff --git a/docs/海报生成尺寸模板与导出问题修复报告_20260731.md b/docs/海报生成尺寸模板与导出问题修复报告_20260731.md new file mode 100644 index 0000000..87a99a3 --- /dev/null +++ b/docs/海报生成尺寸模板与导出问题修复报告_20260731.md @@ -0,0 +1,289 @@ +# 海报生成尺寸、模板与导出问题修复报告 + +日期:2026-07-31 +范围:海报单图/长图画布、模板体系、AI 背景生成、浏览器合成、服务端保存与下载 +结论性质:代码静态审计 + 7 张样图像素核验 + 现有测试核验;本报告不包含代码修改 + +## 1. 结论 + +当前问题不是单个尺寸参数错误,而是三套概念被混成了一个 `exportSize`: + +1. **最终成品尺寸**:用户实际下载图片的像素宽高; +2. **HTML 排版画布尺寸**:文字、图表和模块布局使用的 DOM 尺寸; +3. **AI 背景素材尺寸**:图片模型实际支持并返回的尺寸。 + +这三层目前没有统一、可验证的尺寸契约,导致: + +- 前端展示了后端不支持的尺寸,后端静默回退成 `1024x1792`; +- 长图声明固定高度,但正文又按内容自适应,容易产生大段空白或尺寸失真; +- 单图并非独立模板,而是把长图的六段内容放进固定比例容器后隐藏溢出; +- 模板只定义 AI prompt、配色和参考图,没有定义真正的版式骨架; +- 最终 PNG 依赖用户浏览器再次合成并上传,用户离开页面、浏览器内存不足或上传超限时,后台任务虽然显示完成,下载文件仍可能不存在。 + +综合判断:当前海报生成链路处于“可演示,但不可稳定交付”的状态。需要先修正尺寸和成品生成架构,再继续增加模板。 + +## 2. 样图尺寸核验 + +| 样图 | 实际像素 | 宽高比 | 判断 | +|---|---:|---:|---| +| 图 1 | 1242×9530 | 1:7.67 | 典型多段内容长图,宽度固定、高度随内容增长 | +| 图 2 | 1024×1536 | 2:3 | 文件本身不是长图,而是多个页面被排成 2 列拼版;可作为多章节风格参考,不能作为长图尺寸参考 | +| 图 3 | 1242×9449 | 1:7.61 | 典型多段内容长图 | +| 图 4 | 1242×12440 | 1:10.02 | 内容结束后存在明显空白尾部,是错误高度/错误截取范围的直接表现,不应作为目标尺寸 | +| 图 5 | 900×2209 | 约 9:22 | 超高竖版单图 | +| 图 6 | 992×1586 | 约 5:8 | 竖版单图 | +| 图 7 | 1024×1536 | 2:3 | 标准竖版单图 | + +因此,“长图”和“单图”不能只按一个固定比例区分: + +- **长图**的核心定义是固定宽度、内容驱动高度,建议标准成品宽度为 `1242` 或 `1080`,高度取真实内容高度; +- **单图**的核心定义是固定画幅和单屏信息预算,建议主规格为 `1024×1536(2:3)`,辅规格为 `1080×1920(9:16)`;如确需图 5 风格,可单独增加“超高单页”规格,不能混入普通单图。 + +## 3. 审计健康度 + +| 维度 | 分数 | 核心问题 | +|---|---:|---| +| 可访问性 | 2/4 | 可编辑区域依赖 `contenteditable`,缺少明确标签和键盘状态提示 | +| 性能 | 1/4 | 超长 DOM 使用固定 `pixelRatio: 2` 转 base64 PNG,内存和文件体积不可控 | +| 响应式/尺寸适配 | 1/4 | 预览缩放有所改善,但成品尺寸、内容高度和 AI 素材尺寸没有统一 | +| 主题系统 | 1/4 | 模板色只影响少数组件,正文大量写死白底和绿色 | +| 实现完整性 | 1/4 | 单图/长图共用骨架,模板没有版式能力,最终成品依赖前端在线合成 | +| **总分** | **6/20(Poor)** | **发布前需要结构性修复** | + +## 4. 详细问题 + +### P0-1:前端尺寸预设与图片模型尺寸映射完全不一致 + +证据: + +- 前端长图提供 `1080x2160`、`1080x3240`、`1080x4320`;单图还提供 `1792x1024`、`1080x1440`、`2480x3508` 等规格:`frontend/src/components/poster/workspace/PosterCreativePanel.vue:215-231`。 +- 后端 `SIZE_MAP` 只识别 `1080x1920`、`900x500`、`1080x1080`、`800x1200`:`api/insurance/poster/image_generator.py:9-15`。 +- 所有未命中的规格都会静默回退到 `1024x1792`:`api/insurance/poster/image_generator.py:146-160`。 + +影响:用户选择“超长图”“A4”“横版”等规格时,AI 实际仍可能生成 `1024x1792` 背景。数据库却继续记录用户原始选择,形成“记录尺寸”和“真实文件尺寸”不一致。 + +修复要求:建立唯一尺寸注册表,前后端共享同一组规格;对不支持尺寸必须返回参数错误,禁止静默回退。 + +### P0-2:最终成品依赖浏览器合成,后台任务完成不等于可下载 + +证据: + +- Celery 任务只保存 AI 背景到 `background_file_url`,不会生成带文案和图表的最终海报:`api/insurance/generation/celery_tasks.py:1178-1200`。 +- 最终海报由浏览器执行 `html-to-image`,随后再上传到 `/rendered`:`frontend/src/pages/PosterPage.vue:438-467`。 +- `/download/` 只接受 `export_url`;如果浏览器没有成功上传最终图,即使 `background_file_url` 存在,下载仍返回“文件不存在”:`api/insurance/poster/routes.py:309-327`。 +- 服务端限制最终 PNG 不超过 30 MB:`api/insurance/poster/service.py:422-452`。超长图或高像素图很容易触发此限制。 + +影响:用户关闭页面、切换任务、浏览器崩溃、合成失败或上传超限后,任务仍可能显示完成,但历史记录无法下载。这与目前反馈的“生成出来但导出有问题”高度吻合。 + +修复要求:最终成品必须由服务端任务确定性生成并落盘。浏览器预览可保留,但不应承担唯一成品生成职责。短期内至少应增加独立的 `render_status`,只有最终 PNG 已落盘才显示“完成/可下载”。 + +### P1-1:单图不是独立版式,只是长图容器的裁剪版本 + +证据: + +- 单图和长图均渲染同一个 `PosterHtmlCanvas`,同一组 hero、summary、benefits、features、CTA、disclaimer:`frontend/src/components/poster/long/PosterHtmlCanvas.vue:1-46`。 +- 单图仅设置 `aspect-ratio`,根节点同时设置 `overflow: hidden`:`frontend/src/components/poster/long/PosterHtmlCanvas.vue:92-101,126-140`。 + +影响:内容多时会被裁掉或挤压;内容少时会留下空白。图 5~7 那种“一张图内完成主视觉、核心利益和数据条”的单图构图,当前骨架无法稳定实现。 + +修复要求:拆分为至少两个成品组件: + +- `PosterSingleCanvas`:固定画幅,严格信息预算,主视觉与数据一体化; +- `PosterLongCanvas`:固定宽度,章节按内容自然增长。 + +### P1-2:长图同时使用“预设固定高度”和“内容自适应高度” + +证据: + +- 长图画布设置 `minHeight: h`,实际高度又由所有章节内容撑开:`frontend/src/components/poster/long/PosterHtmlCanvas.vue:92-98`。 +- 长图尺寸预设给出 2160、3240、4320 三个固定高度:`PosterCreativePanel.vue:215-222`。 + +影响:当内容不足时产生空白尾部,图 4 已经直观展示这一问题;当内容超过预设时,最终高度又不再等于选择值。`exportSize` 因而既不是目标尺寸,也不是真实尺寸。 + +修复要求:长图取消固定高度预设,改为“成品宽度 + 自动高度”。如果业务确实需要高度档位,应把它定义为内容预算或最大高度,并在超出时分片,而不是 `min-height`。 + +### P1-3:模板只是“背景提示词”,不是版式模板 + +证据: + +- `PosterTemplate` 只有名称、场景标签、风格描述、配色、参考图和预览图,没有 `layout_key`、支持模式、支持比例或章节规则:`api/insurance/models/poster_template_model.py:6-39`。 +- 模板列表不按 `single/long` 过滤,同一模板可直接用于所有输出模式:`frontend/src/components/poster/workspace/PosterCreativePanel.vue:256-315`。 +- HTML 正文布局始终是同一套组件。 + +影响:选择不同模板时,主要变化只是 AI 背景和少量颜色,无法产生样例中真正不同的章节节奏、数据板式、主视觉占比和信息密度。 + +修复要求:模板模型至少新增: + +- `layout_key`:对应真实前端/服务端布局实现; +- `supported_modes`:`single`、`long` 或两者; +- `supported_aspect_ratios`; +- `section_schema`:允许的章节、顺序、是否必选、内容上限; +- `background_strategy`:hero、full-bleed、section、none; +- `version`:保证旧海报可重复渲染。 + +### P1-4:模板配色没有贯穿整张海报 + +证据: + +- hero 和 CTA 会读取模板主色/强调色; +- summary、benefit chart、feature cards 和 disclaimer 大量写死 `#1a3a2a`、`#2d6a4f`、白色和固定灰色:`frontend/src/components/poster/long/PosterSummaryCards.vue:57-93`、`PosterBenefitChart.vue:122-169`、`PosterFeatureCards.vue:35-108`、`PosterDisclaimer.vue:27-52`。 + +影响:暖色、深蓝、深绿等模板最终正文仍呈现同一套白底绿色组件,无法还原图 1~4 的整页主题一致性。 + +修复要求:用画布级 CSS variables/tokens 统一控制背景、正文、弱文本、卡片、边框、主色和强调色;每个布局只消费 token,不再写死品牌颜色。 + +### P1-5:AI 背景尺寸与最终画布职责混乱 + +证据: + +- prompt 把最终 `size` 直接作为 AI 图片尺寸要求:`api/insurance/poster/image_generator.py:117-123`。 +- 图片 API 实际只接受有限尺寸,长图尺寸无法原生生成;返回图随后仅作为 hero 的 `background-size: cover` 背景:`frontend/src/components/poster/long/PosterHero.vue:30-39,49-56`。 + +影响:系统一方面要求模型生成整张长图尺寸,另一方面只把结果裁进顶部 360px hero。长图模板信息没有真正作用于正文,参考图若含完整排版,还可能诱导模型生成拼版、伪文字或多页面画面。图 2 的“多页拼成一张”正是需要防止的结果类型。 + +修复要求:AI 只生成明确用途的素材,例如 `hero_background_1536x1024`;最终海报尺寸完全由布局渲染器负责。prompt 必须明确安全文字区、人物位置、焦点和裁切策略,而不是传最终长图高度。 + +### P1-6:导出像素比固定为 2,真实尺寸与选择值无关 + +证据: + +- `html-to-image` 固定使用 `pixelRatio: 2`:`frontend/src/pages/PosterPage.vue:438-449`。 +- HTML 画布宽度又被限制为最多 1080px:`frontend/src/components/poster/long/PosterHtmlCanvas.vue:92-100`。 + +影响:选择 1024 宽时实际导出可能为 2048 宽;选择 2480 宽时 DOM 仍只有 1080 宽,实际导出约 2160 宽;数据库记录的 `exportSize` 均不能代表真实输出。长图还会产生数千万像素的中间 canvas 和 base64 字符串,明显增加浏览器崩溃、黑图、空图或上传超限概率。 + +修复要求:以目标成品宽度反推导出 scale,导出后解码 PNG 校验真实宽高。长图使用 Blob 流程,避免 data URL;设置最大像素总量,超过阈值自动降低 scale 或按章节分片。 + +### P2-1:服务端只检查 PNG 文件头,不校验真实尺寸和完整性 + +`save_rendered` 只检查 30 MB 上限和 PNG signature,没有用 Pillow 解码,也没有对比期望尺寸:`api/insurance/poster/service.py:422-452`。 + +修复要求:解码并验证图片完整性,记录 `actual_width`、`actual_height`、`byte_size`、`sha256`;单图必须符合比例容差,长图必须符合固定宽度和最大像素限制。 + +### P2-2:尺寸变更没有进入文档自动保存监听 + +`buildPosterDocument()` 包含 `exportSize`,但自动保存 watcher 没有监听 `exportSize`:`frontend/src/pages/PosterPage.vue:366-400`。 + +影响:用户生成后修改尺寸但没有再次下载时,服务端文档可能保持旧尺寸。 + +### P2-3:现有测试没有覆盖尺寸和导出链路 + +本次执行 `tests/ppt_poster_optimization_test.py`,13 项全部通过;但现有测试只覆盖字段画像、内容裁剪、合规和基础生成前置条件,没有覆盖: + +- 所有前端规格能否被后端识别; +- AI 返回尺寸与请求尺寸是否一致; +- 单图是否裁切内容; +- 长图是否存在空白尾部; +- 最终 PNG 的真实像素; +- 关闭页面后历史任务是否仍可下载; +- 30 MB、超长 canvas、图片加载失败和图表未完成渲染。 + +### P2-4:当前工作区的 `PosterStage.vue` 尾部存在游离 CSS + +`` 后仍残留一段没有选择器的 CSS 声明。`vue-tsc --noEmit` 当前可以通过,但该内容属于无效/无归属实现,说明最近预览缩放调整尚未完成清理。它不是本次尺寸问题的主因,但修复时应一并做构建级校验。 + +## 5. 推荐目标架构 + +### 5.1 尺寸契约 + +建议定义统一的 `PosterFormatSpec`: + +```text +id single_2_3 | single_9_16 | long_auto_1242 +mode single | long +canvasWidth 1024 | 1080 | 1242 +canvasHeight 固定值或 auto +aspectRatio 单图必填,长图为空 +maxPixelCount 浏览器/服务端安全阈值 +assetSpecs hero 背景等 AI 素材的独立尺寸 +allowedLayouts 可使用的 layout_key +``` + +前端选项、后端校验、任务快照和导出器必须读取同一份规格。数据库同时保存请求规格 ID 和最终真实像素。 + +### 5.2 两类独立布局 + +**单图模板**: + +- 主视觉占 50%~70%; +- 标题、3~5 个核心数据、最多 3 个卖点、免责声明; +- 禁止完整收益图表和六段长文; +- 默认 `1024×1536`,可选 `1080×1920`。 + +**长图模板**: + +- 固定宽度 `1242` 或 `1080`; +- hero、方案摘要、收益趋势、里程碑卡片、优势、适配人群、CTA、免责声明分章节; +- 高度按真实内容计算; +- 超过最大像素总量时输出多张连续图片或服务端分片后再拼接。 + +### 5.3 成品生成 + +推荐优先级: + +1. **服务端 Headless Chromium 渲染最终 HTML 海报**,确保任务完成后一定存在最终 PNG; +2. 浏览器只负责可编辑预览和低分辨率即时预览; +3. AI 只生成背景素材,不生成文字、数字、Logo、表格或整张海报; +4. 最终文件保存前执行像素、体积和解码校验; +5. 下载接口只返回通过校验的最终文件,并明确区分背景素材和最终成品。 + +## 6. 分阶段修复计划 + +### 第一阶段:止血(P0,预计 1~2 天) + +1. 删除前端所有后端不支持的伪规格,或补齐严格映射并拒绝未知规格; +2. 单图默认改为 `1024×1536`;长图改为固定宽度 + 自动高度; +3. 去掉固定 `pixelRatio: 2`,按目标宽度计算输出 scale; +4. 导出后校验真实 PNG 宽高和文件体积; +5. 任务状态拆成“背景生成完成”和“最终成品完成”,没有 `export_url` 不得显示可下载; +6. 下载失败时返回明确原因,禁止用“文件不存在”掩盖合成未完成。 + +### 第二阶段:版式重构(P1,预计 3~5 天) + +1. 拆分 `PosterSingleCanvas` 与 `PosterLongCanvas`; +2. 为模板增加 `layout_key`、支持模式、比例和章节 schema; +3. 模板列表按输出模式和比例过滤; +4. 建立完整主题 token,移除正文硬编码绿色; +5. AI 背景改成独立 asset spec,不再接收最终长图高度。 + +### 第三阶段:可靠导出(P1,预计 2~4 天) + +1. 将最终海报迁移到服务端 Headless Chromium 渲染; +2. 增加超长图最大像素和分片策略; +3. 保存真实宽高、字节数、哈希、渲染器版本和模板版本; +4. 历史记录支持重新渲染,不依赖原浏览器会话。 + +### 第四阶段:回归验证(P1/P2,预计 1~2 天) + +至少建立以下金丝雀用例: + +1. `single_2_3` 输出严格为 `1024×1536`; +2. `single_9_16` 输出严格为 `1080×1920`; +3. `long_auto_1242` 输出宽度严格为 `1242`,底部空白不超过设计 footer; +4. 同一份数据在单图中不出现完整收益表,在长图中章节齐全; +5. 所有模板只出现在兼容模式中; +6. 用户提交后立即关闭页面,任务仍能生成并下载最终海报; +7. 超大长图不会产生黑图、空图、截断或超过服务端限制; +8. 下载文件实际尺寸、数据库记录和 UI 显示三者完全一致。 + +## 7. 验收标准 + +修复完成必须同时满足: + +- 单图和长图具有不同布局组件、不同内容预算和不同模板白名单; +- 任何尺寸都没有静默回退; +- 长图底部不出现由固定 `min-height` 造成的大段空白; +- 导出 PNG 的真实像素与规格一致,并被服务端解码校验; +- 后台显示“完成”时,即使原页面已关闭,历史记录也能直接下载最终成品; +- 模板色彩和版式覆盖整张海报,而不是只改变 hero/CTA; +- 自动化测试覆盖尺寸注册表、真实像素、超长图和无浏览器会话下载。 + +## 8. 正向发现 + +- 已经把 AI 图片定位为“无文字背景”,方向是正确的; +- `PosterHtmlCanvas` 已按章节组件化,拆分单图/长图时可以复用数据和部分章节; +- 预览缩放已经使用独立 shell + transform,思路合理; +- 导出前已经等待字体和背景资源加载,比直接截图稳定; +- 字段画像已区分单图/长图的信息预算,后续可直接用于布局白名单。 + +这些基础可以保留,但必须先补齐尺寸契约、独立布局和服务端最终渲染三个核心环节。 diff --git a/docs/海报生成问题报告复核与详细修复计划书_20260731.md b/docs/海报生成问题报告复核与详细修复计划书_20260731.md new file mode 100644 index 0000000..186d2a8 --- /dev/null +++ b/docs/海报生成问题报告复核与详细修复计划书_20260731.md @@ -0,0 +1,753 @@ +# 海报生成问题报告复核与详细修复计划书 + +日期:2026-07-31 +复核对象:`docs/海报生成尺寸模板与导出问题修复报告_20260731.md` +实施范围:`api/insurance/poster/`、`api/insurance/generation/` 中的海报任务、`frontend/src/components/poster/`、`frontend/src/pages/PosterPage.vue` 及对应测试 +约束:不修改 BaoDan/Dify 基座文件;所有后端自研改动继续位于 `api/insurance/`;本计划不直接修改业务代码 + +## 1. 执行摘要 + +上一份报告对以下主问题判断正确: + +- 单图和长图共用一套排版骨架; +- 前端尺寸选项与图片模型支持尺寸不一致; +- 模板只有风格提示能力,没有真正的版式能力; +- `pixelRatio: 2` 使实际导出尺寸失控; +- 最终文件依赖浏览器合成,任务状态与可下载状态存在错位。 + +但上一份报告不能直接作为开发计划使用,原因是: + +1. 它虽然提出了三层尺寸概念,后续方案却再次把 **CSS 逻辑排版宽度** 与 **最终 PNG 像素宽度** 混在一起; +2. 它把服务端 Headless Chromium 当成必选修复,没有先验证较小、较快的浏览器导出修复能否满足当前业务; +3. 部分结论属于推断而不是已复现事实,例如图 4 空白一定来自当前系统、长图很容易超过 30 MB; +4. 它漏掉了两个重要根因:**渲染数据来源分裂**,以及 **fallback 背景仍会绘制文字**; +5. 缺少文件级任务、接口契约、兼容策略、灰度方案、回滚方案和可执行验收用例。 + +本计划采用“先建立可测基线,再做最小闭环修复,最后决定是否引入服务端浏览器”的顺序。推荐目标工期为 **13~19 人日**;若确认必须支持“用户关闭页面后仍自动产出最终海报”,再增加服务端渲染阶段,预计额外 **4~7 人日**。 + +## 2. 本计划的工作假设 + +在没有新的产品确认前,按以下假设推进,实施前第 0 阶段必须由产品负责人确认: + +- 图 1~4 表示“完整方案/高信息密度”的设计语言,其中图 2 是多面板拼版参考,不直接作为连续长图的物理尺寸标准; +- 图 5~7 表示单屏竖版海报的设计语言; +- 单图首发只保留 `2:3` 和 `9:16` 两种规格; +- 连续长图使用固定逻辑宽度、内容自动高度,首发只保留一个导出宽度; +- 先保留当前 AI“只生成背景素材”的路线,文字、数字、图表和 Logo 由确定性布局渲染; +- 第一版先修好当前浏览器导出链路,服务端 Headless Chromium 是否上线由可靠性门槛决定; +- 当前工作区已经存在其他未提交修改,实施时不得覆盖或重排无关改动。 + +## 3. 对上一份报告的问题复核 + +### 3.1 尺寸分层提出正确,但目标模型仍不够准确 + +报告区分了最终尺寸、HTML 画布尺寸和 AI 素材尺寸,这是正确的。但它随后建议把长图 `canvasWidth` 直接设为 `1242` 或 `1080`,仍然混淆了两种宽度。 + +当前海报组件使用 `28px` 标题、`12~18px` 正文、`20~32px` padding,这套字号更像为约 `390~540 CSS px` 的逻辑画布设计。如果直接在 `1080 CSS px` 画布上排版,预览缩小后文字和间距会显得过小;这正是目前视觉密度不足的重要原因之一。 + +正确模型应为: + +```text +逻辑排版尺寸:决定文字、间距、网格和组件构图,例如 414 CSS px 宽 +导出缩放倍率:例如 3 +最终成品尺寸:414 × 3 = 1242 px 宽 +AI 素材尺寸:由 hero/full-bleed 素材槽位和供应商能力单独决定 +``` + +因此修复中必须同时存在 `layoutWidth` 与 `outputWidth`,禁止继续使用一个 `exportSize` 表达全部含义。 + +### 3.2 `1242px` 只能作为候选导出宽度,不能直接当成产品标准 + +图 1、3、4 都是 `1242px` 宽,但这也可能来自手机截图或三倍屏导出。没有渠道规范、文件上传限制和目标设备信息,不能仅凭样图认定所有长图必须输出 `1242px`。 + +计划中将 `1242px` 作为首选候选,同时保留在第 0 阶段改为 `1080px` 的决策点。两者不应同时首发,避免继续扩大测试矩阵。 + +### 3.3 图 2 和图 4 的结论过于确定 + +- 图 2 是 `1024×1536` 的多面板拼版。它可能是设计参考,也可能是模型错误输出;没有生成上下文时不能直接判定为失败。 +- 图 4 的空白尾部确实符合错误截取范围,但尚未证明它由当前 `min-height` 直接生成。 + +修复时应把这两项变成复现用例,而不是把推断当成已确认根因。 + +### 3.4 30 MB 风险没有数据支撑 + +7 张样图实际文件大小约为 `0.20~4.60 MB`,均远低于服务端的 30 MB 限制。固定 `pixelRatio: 2` 和 data URL 的确存在浏览器内存风险,但“很容易超过 30 MB”没有被样本证明。 + +计划应分别记录: + +- PNG 压缩后文件大小; +- 解码后的像素总量; +- 浏览器合成峰值内存; +- 上传限制。 + +文件大小和内存占用不是同一个问题,不能混为一谈。 + +### 3.5 服务端 Headless Chromium 是可选架构决策,不是第一步 + +服务端 Chromium 会引入浏览器二进制、字体、worker 内存、Docker 镜像体积、内部渲染鉴权和版本一致性问题。当前项目没有 Playwright/Puppeteer 依赖,直接迁移会扩大改动面。 + +更稳妥的顺序是: + +1. 先修正布局、尺寸契约和浏览器 Blob 导出; +2. 记录真实失败率和关闭页面后的业务要求; +3. 如果必须支持无页面会话生成,或浏览器导出失败率超过门槛,再引入服务端渲染。 + +### 3.6 审计健康分数对本次决策帮助有限 + +`6/20` 混入了可访问性、主题和响应式等维度,但本次核心是成品尺寸、版式和导出可靠性。没有运行时页面和用户任务数据,分数存在伪精确感。 + +本计划改用可验证门槛:真实像素是否一致、内容是否裁切、空白是否超标、关闭页面后是否需要完成、下载成功率和视觉基准是否通过。 + +### 3.7 报告漏掉了渲染数据来源分裂 + +当前至少存在三份内容来源: + +- 前端画布直接使用 `draft.parsedFields`; +- Celery 的 `build_poster_content()` 使用 `product_rules.get("facts") or product_rules`; +- 最终 `document_json` 又保存 case 的 `confirmed_data`。 + +而 `poster_content` 只用于 AI prompt,并没有成为 HTML 画布的统一输入。产品小册子的 features、计划书确认数据、字段画像和前端 sections 因而可能不一致。 + +这会造成“AI 背景理解的是一组卖点,画布展示的是另一组数据”。修复必须先建立唯一 `renderDocument`,不能只改 CSS。 + +### 3.8 报告漏掉了 fallback 图片会绘制文字 + +正常 prompt 要求 AI 只生成无文字背景,但 `generate_fallback()` 会在图片中绘制 headline、body 和 CTA。该图片随后又作为 hero 背景,前端 HTML 会再次绘制同样文案,可能产生重复文字、裁切文字或乱码。 + +fallback 必须改为无文字的纯背景素材,或者在背景生成失败时直接使用模板渐变,不能再生成“半成品海报”。 + +### 3.9 报告缺少落地约束 + +原报告没有明确: + +- 首发到底支持哪些规格; +- 哪些旧记录继续使用 v1; +- 模板数据如何迁移; +- 模式切换后 sections 和 template 如何处理; +- 如何验证 ECharts 已完成渲染; +- 如何灰度、监控和回滚; +- 每个阶段改哪些文件以及完成定义。 + +以下计划补齐这些内容。 + +## 4. 目标产品规格 + +### 4.1 输出模式与规格 + +首发建议只支持三种格式,避免继续开放不可验证的“自定义尺寸”: + +| formatId | 用途 | 逻辑尺寸 | 最终像素 | 高度策略 | +|---|---|---:|---:|---| +| `single_2_3` | 朋友圈/私聊单图 | 512×768 CSS px | 1024×1536 | 固定 | +| `single_9_16` | 竖屏故事/企微 H5 | 540×960 CSS px | 1080×1920 | 固定 | +| `long_1242_auto` | 完整方案长图 | 414×auto CSS px | 1242×auto | 内容驱动 | + +第 0 阶段如果确认渠道统一要求 `1080px` 长图,则把最后一项改为 `long_1080_auto`,逻辑宽度仍保持独立,不改变组件排版原则。 + +首发明确不支持:横版、方图、A4、自定义宽高、固定 2160/3240/4320 高度。以后增加格式时,每增加一个格式都必须同时增加视觉基准和导出测试。 + +### 4.2 内容预算 + +**单图:** + +- 1 个品牌区; +- 1 个主标题,建议不超过 24 个中文字符; +- 1 个副标题,最多 2~3 行; +- 3~5 个核心数据; +- 最多 3 个卖点; +- 1 个 CTA; +- 1 条精简免责声明; +- 不展示完整收益折线图和多年度卡片矩阵。 + +**长图:** + +- hero; +- 计划摘要; +- 收益趋势图(有至少 3 个有效节点时); +- 里程碑数据卡; +- 最多 6 个产品卖点; +- 适配/不适配说明; +- CTA; +- 完整免责声明; +- 高度完全由实际可见章节决定,不设置人为 `min-height`。 + +### 4.3 模板定义 + +首发不做无限可配置模板系统,只增加解决当前问题所需的最少字段: + +```text +layoutKey single_hero_data | single_story | long_editorial +supportedModes [single] | [long] +supportedFormats [single_2_3, ...] +themeTokens 背景、文字、卡片、边框、主色、强调色 +backgroundSlot hero | full_bleed +version 整数 +``` + +`section_schema`、任意拖拽布局和管理员可视化模板编辑器不进入本次范围。 + +## 5. 目标技术模型 + +### 5.1 格式注册表 + +在后端建立唯一格式注册表,例如: + +`api/insurance/poster/format_registry.py` + +职责: + +- 返回首发格式列表; +- 校验 `formatId` 与 `outputMode`; +- 提供逻辑尺寸、最终尺寸、最大高度和 AI asset slot; +- 拒绝未知格式,不再静默回退。 + +新增只读接口: + +```http +GET /insurance/poster/formats +``` + +前端从该接口加载选项,避免 TypeScript 与 Python 各维护一份尺寸清单。接口不可用时只回退到内置的三项安全规格,不开放自定义尺寸。 + +为了兼容旧记录,保留数据库 `export_size` 字段;新记录同时在 `document_json` 中保存 `formatId`、`requestedOutput` 和 `actualOutput`。第一期不为了实际宽高额外增加数据库列,避免不必要迁移。 + +### 5.2 统一渲染文档 + +新增后端构建器,例如: + +`api/insurance/poster/render_document_builder.py` + +统一输出: + +```json +{ + "schemaVersion": 2, + "formatId": "single_2_3", + "outputMode": "single", + "layoutKey": "single_hero_data", + "copy": {}, + "facts": {}, + "features": [], + "benefits": [], + "theme": {}, + "sections": [], + "background": {}, + "complianceRevision": "" +} +``` + +数据优先级固定为: + +1. 人工确认的 case `confirmed_data`; +2. 产品小册子规则中的产品卖点和保障规则; +3. 模板默认值; +4. 安全空状态。 + +Celery prompt、前端预览、最终导出和历史恢复都读取同一份文档,不再分别拼装。 + +### 5.3 画布路由 + +新增轻量路由组件: + +```text +PosterCanvasRouter +├── PosterSingleCanvas +└── PosterLongCanvas +``` + +章节组件可以复用,但容器、信息预算和排版规则必须独立。`PosterHtmlCanvas` 不再通过 `aspect-ratio + overflow:hidden` 模拟单图。 + +模式或格式切换时必须执行: + +1. 重新计算兼容 layout; +2. 过滤不兼容 template; +3. 按内容预算规范化 sections; +4. 告知用户哪些内容被隐藏,而不是静默裁切; +5. 标记当前最终成品为过期,需要重新导出。 + +### 5.4 AI 背景素材 + +图片模型只接收素材槽位规格,不接收最终长图高度: + +```text +最终长图:1242×auto +逻辑 hero:414×360 CSS px +AI 素材:由 provider capability 映射到最接近的竖版或横版背景尺寸 +``` + +图片供应商尺寸解析必须严格: + +- 已支持规格明确映射; +- 未支持规格返回可诊断错误; +- 不得无提示回退到 `1024x1792`; +- 记录 requested asset size 和 actual asset size; +- fallback 只生成无文字渐变/纹理背景。 + +### 5.5 浏览器导出 v2 + +第一版继续使用浏览器导出,但必须改成确定性流程: + +1. 等待字体完成; +2. 等待全部图片 decode; +3. 等待 ECharts `finished` 事件,而不是只等待两个 animation frame; +4. 读取未缩放画布的逻辑尺寸; +5. 根据 `outputWidth / layoutWidth` 计算唯一导出倍率; +6. 单图固定输出宽高;长图以真实内容高度计算输出高度; +7. 使用 Blob 导出,不经 base64 data URL 中转; +8. 客户端解码 Blob 核验宽高; +9. 服务端用 Pillow 再次解码并校验; +10. 校验通过后才写入 `export_url` 并显示“可下载”。 + +长图安全门槛首版建议: + +- 最终像素高度超过浏览器验证上限时阻止导出并给出明确提示; +- 不在本次首发中自动降采样,因为静默降低质量会再次造成记录和成品不一致; +- 分片导出作为后续功能,只有真实案例超过上限后再实现。 + +## 6. 分阶段实施计划 + +## 阶段 0:冻结基线与复现(1~2 人日) + +### 目标 + +把推断变成可重复证据,确认首发格式。 + +### 任务 + +1. 保存当前工作区差异清单,标记海报相关未提交改动的负责人; +2. 固定一份脱敏计划书数据、产品规则、模板和文案作为测试 fixture; +3. 对当前版本分别导出: + - single 默认规格; + - long 2160; + - long 4320; +4. 记录:选择尺寸、DOM 尺寸、PNG 真实尺寸、像素总量、Blob 大小、上传结果和下载结果; +5. 验证图 4 空白是否可由当前代码稳定复现; +6. 验证图 2 是否属于允许的多面板设计方向; +7. 确认长图首发输出宽度为 1242 或 1080; +8. 确认是否存在“用户关闭页面后必须继续完成最终成品”的硬需求。 + +### 产出 + +- `tests/fixtures/poster/` 脱敏 fixture; +- 当前版本导出基线表; +- 三个首发 formatId 的最终确认; +- 服务端 Headless 是否必须进入本期的决策记录。 + +### 完成定义 + +同一 fixture 在同一浏览器连续导出 3 次,能够稳定复现当前尺寸问题,并获得真实 PNG 元数据。 + +## 阶段 1:尺寸契约止血(2~3 人日) + +### 后端任务 + +- 新增 `format_registry.py`; +- 新增 `GET /poster/formats`; +- `PosterService.generate_poster()` 改为接收 `formatId`; +- 旧请求中的 `size` 仅用于兼容映射,无法映射时返回参数错误; +- `PosterImageGenerator` 将最终格式与 AI asset size 分离; +- 删除未知尺寸默认回退; +- `generate_fallback()` 改成无文字背景。 + +### 前端任务 + +- `PosterDraft` 新增 `formatId`; +- `PosterCreativePanel.vue` 从 formats API 渲染规格; +- 删除 A4、横版、方图和自定义尺寸入口; +- outputMode 切换时自动选择第一个兼容格式; +- `buildPosterDocument()` 和自动保存 watcher 纳入 `formatId`。 + +### 主要文件 + +- `api/insurance/poster/format_registry.py`(新增) +- `api/insurance/poster/routes.py` +- `api/insurance/poster/service.py` +- `api/insurance/poster/image_generator.py` +- `frontend/src/utils/poster-api.ts` +- `frontend/src/composables/usePosterWorkspace.ts` +- `frontend/src/components/poster/workspace/PosterCreativePanel.vue` +- `frontend/src/pages/PosterPage.vue` + +### 测试 + +- 每个 formatId 都能返回唯一逻辑/输出规格; +- 未知 formatId 返回 4xx 业务错误; +- 旧 `1024x1792` 记录可映射到安全兼容格式; +- 所有前端可见格式均被后端接受; +- fallback PNG 不包含 headline/body/CTA 绘制逻辑。 + +### 完成定义 + +UI 中不存在任何会被后端静默改成其他尺寸的选项。 + +## 阶段 2:统一渲染文档(2~3 人日) + +### 后端任务 + +- 新增 `render_document_builder.py`; +- case confirmed facts、product rules、copy、template、profile 合并为 schema v2; +- `build_poster_content()` 明确接收 case facts 与 product rules,修复当前来源混用; +- record 创建时立即保存完整 renderDocument; +- Celery 从 renderDocument 构建背景 prompt; +- 历史恢复返回原始 schemaVersion,禁止用最新规则偷偷改变旧成品。 + +### 前端任务 + +- `PosterDraft` 增加 `renderDocument` 或等价强类型字段; +- 画布从 renderDocument 读取 facts、features、benefits、theme 和 sections; +- 删除 `defaultFeatures` 从 `parsedFields.key_benefits` 临时拼装的逻辑; +- 编辑操作只修改 renderDocument 对应字段。 + +### 主要文件 + +- `api/insurance/poster/render_document_builder.py`(新增) +- `api/insurance/poster/content_builder.py` +- `api/insurance/poster/service.py` +- `api/insurance/generation/celery_tasks.py` +- `frontend/src/composables/usePosterWorkspace.ts` +- `frontend/src/components/poster/workspace/PosterStage.vue` +- `frontend/src/pages/PosterPage.vue` + +### 测试 + +- 人工确认数据覆盖 AI 原始解析数据; +- 产品 features 能进入前端画布; +- 同一 renderDocument 生成的 prompt、预览和导出使用相同事实; +- schema v1 旧记录仍能恢复; +- 缺失收益表时不显示空图表。 + +### 完成定义 + +任意一个展示字段都能追溯到 renderDocument 的唯一字段,不再从三个不同对象临时拼接。 + +## 阶段 3:拆分单图/长图版式与模板(4~6 人日) + +### 数据和模板任务 + +- 为 `poster_templates` 增加最少兼容字段;迁移编号使用实施时的下一个可用编号,避免与当前未提交迁移冲突; +- 为现有模板补齐默认 `layoutKey`、supportedModes 和 supportedFormats; +- 旧模板无法识别时回退到明确的 legacy layout,不自动套新布局。 + +### 前端任务 + +- 新增 `PosterCanvasRouter.vue`; +- 新增 `single/PosterSingleCanvas.vue`; +- 将现有 `long/PosterHtmlCanvas.vue` 收敛为真正的 `PosterLongCanvas.vue`; +- 抽取共享的事实格式化、免责声明和品牌组件,不抽象视觉结构; +- 建立画布级 theme CSS variables; +- summary/chart/features 等组件移除硬编码绿色; +- 单图只渲染允许的信息预算; +- 长图移除 `minHeight: export height`; +- 模板选择按 mode/format 过滤; +- 切换模式时显示内容调整提示。 + +### 单图构图要求 + +- 主视觉是第一视觉焦点; +- 标题、核心数据条和 3 个卖点在一屏内完整可读; +- footer 和免责声明始终可见; +- 不依赖 `overflow:hidden` 裁掉正文。 + +### 长图构图要求 + +- 章节间有明确的色块和节奏变化; +- 背景和 theme 贯穿全部章节; +- 内容结束后立即进入 footer,不存在人为尾部留白; +- 图表和数据卡具有适合 414 CSS px 逻辑宽度的字号。 + +### 主要文件 + +- `api/insurance/models/poster_template_model.py` +- `api/insurance/db/migrate_0xx.py`(使用实际下一个编号) +- `api/insurance/admin/ppt_admin_service.py` +- `frontend/src/components/poster/PosterCanvasRouter.vue`(新增) +- `frontend/src/components/poster/single/PosterSingleCanvas.vue`(新增) +- `frontend/src/components/poster/long/PosterHtmlCanvas.vue` +- `frontend/src/components/poster/long/*.vue` +- `frontend/src/components/poster/workspace/PosterCreativePanel.vue` +- `frontend/src/components/poster/workspace/PosterSectionPanel.vue` + +### 视觉基准 + +至少建立三张基准图: + +- 单图 2:3; +- 单图 9:16; +- 标准长图。 + +使用同一 fixture 与批准样图逐项对比:信息层级、主视觉占比、文字可读性、数据完整性、章节节奏、footer 和空白尾部。 + +### 完成定义 + +单图和长图在 DOM 结构、内容预算和模板白名单上均相互独立;隐藏任意章节后不会破坏剩余布局。 + +## 阶段 4:可靠导出 v2(2~3 人日) + +### 前端任务 + +- 将 `renderComposite()` 拆到独立 `poster-exporter.ts`; +- 导出时使用未应用预览 transform 的画布; +- 根据 format spec 计算导出倍率; +- 单图固定宽高,长图读取真实内容高度; +- 将 ECharts ready 纳入资源屏障; +- 使用 Blob API; +- 导出后在客户端校验真实像素; +- 导出状态明确区分 `preparing`、`rendering`、`uploading`、`ready`、`failed`; +- 对失败给出具体错误,不直接回退下载旧文件。 + +### 后端任务 + +- `save_rendered()` 使用 Pillow `verify()` 和重新打开读取尺寸; +- 校验实际尺寸是否符合 format spec; +- 将 actualOutput、byteSize 和校验结果保存到 document/extraData; +- 只有验证通过才写 `export_url`; +- `/download/` 在最终文件未就绪时返回“成品仍在准备/需要重新导出”,不返回模糊的“文件不存在”。 + +### 主要文件 + +- `frontend/src/utils/poster-exporter.ts`(新增) +- `frontend/src/pages/PosterPage.vue` +- `frontend/src/components/poster/long/PosterBenefitChart.vue` +- `frontend/src/components/poster/workspace/PosterStage.vue` +- `api/insurance/poster/service.py` +- `api/insurance/poster/routes.py` + +### 测试 + +- `single_2_3` 必须严格输出 1024×1536; +- `single_9_16` 必须严格输出 1080×1920; +- `long_1242_auto` 宽度严格为 1242,高度等于内容测量值乘导出倍率; +- 图表在导出图中非空; +- 服务端拒绝损坏 PNG、错误尺寸和不匹配 formatId 的文件; +- 导出失败不会覆盖上一版有效文件; +- 同一文档连续导出三次尺寸一致。 + +### 完成定义 + +UI 规格、renderDocument、PNG 真实尺寸和服务端记录四者一致。 + +## 阶段 5:可靠性决策门(0.5 人日评审) + +完成阶段 4 后,使用至少 20 次真实或仿真导出评估: + +- 浏览器最终合成成功率是否达到 99%; +- 长图最大像素下是否出现黑图/空图/截断; +- 是否存在明确业务要求:用户提交后关闭页面,仍必须自动完成最终海报; +- worker/Docker 是否允许增加 Chromium 的资源成本。 + +只有满足以下任一条件,才进入服务端 Headless 阶段: + +- 关闭页面后自动成品是硬需求; +- 浏览器导出成功率低于 99%; +- 多端浏览器结果无法保持一致; +- 历史任务必须支持无人值守重新渲染。 + +## 阶段 6(条件性):服务端 Headless 渲染(额外 4~7 人日) + +### 任务 + +- 评估 Playwright/Chromium 进入 worker 镜像的体积和内存; +- 建立只接受短时签名 token 的内部 render route; +- 固定字体、浏览器版本、viewport、device scale 和 locale; +- Celery 在背景素材完成后打开 render route,生成最终 PNG; +- 最终任务状态只有在 PNG 校验并落盘后才变为 done; +- 增加超时、重试、幂等和旧成品保护; +- 保留浏览器本地导出作为人工应急能力。 + +### 完成定义 + +用户生成后立即关闭页面,任务仍能在历史记录中完成,并下载与预览一致的最终海报。 + +## 7. 测试计划 + +### 7.1 后端单元测试 + +新增或扩展: + +- format registry 映射和拒绝规则; +- renderDocument 数据优先级; +- single/long 字段预算; +- template 兼容过滤; +- fallback 无文字; +- PNG 解码、尺寸和格式校验; +- schema v1/v2 兼容恢复。 + +建议文件: + +- `tests/poster_format_registry_test.py` +- `tests/poster_render_document_test.py` +- `tests/poster_export_validation_test.py` + +### 7.2 前端组件测试 + +当前前端未配置组件测试框架。阶段 3 开始前决定是否引入 Vitest;若不引入,至少用构建检查和 E2E 覆盖以下逻辑: + +- 模式切换自动修正规格与模板; +- single 不渲染 forbidden sections; +- long 高度随内容变化; +- export scale 计算; +- 资源未 ready 时禁止导出; +- 导出失败状态和重试。 + +### 7.3 端到端与视觉回归 + +固定浏览器、字体和 fixture,验证: + +1. 新建海报; +2. 选择产品并恢复确认数据; +3. 切换 single/long; +4. 选择兼容模板; +5. 生成背景; +6. 导出并下载; +7. 使用 Pillow 校验 PNG 元数据; +8. 与批准基准图做截图差异比较; +9. 刷新页面并重新下载; +10. 导出失败后旧成品仍可用。 + +### 7.4 性能边界 + +以像素总量而不是文件 MB 作为主要边界: + +- 标准单图; +- 标准长图; +- 2 倍典型长图内容; +- 最大允许长图; +- 低内存移动设备只做预览,不要求在移动端导出最大长图时必须成功;如业务要求移动端导出,则服务端渲染直接升级为硬需求。 + +## 8. 兼容、灰度与回滚 + +### 8.1 旧记录 + +- `schemaVersion` 缺失视为 v1; +- v1 记录继续使用 legacy renderer,不用 v2 规则重新排版; +- v1 可下载文件保持原路径; +- 用户主动选择“升级并重新生成”时才转为 v2。 + +### 8.2 功能开关 + +增加项目级配置 `poster_render_v2`: + +- 关闭:继续使用旧布局和旧导出; +- 开启:新建记录使用 format registry、renderDocument v2 和新布局; +- 灰度:仅管理员/测试用户开启。 + +### 8.3 数据迁移 + +- 模板字段只做增量新增,不删除旧字段; +- 给现有模板补兼容默认值; +- 不重写旧 `document_json`; +- 迁移脚本编号必须在实施时检查 `api/insurance/db/` 当前最大编号,避免与未提交的 `migrate_029.py`、`migrate_030.py` 冲突。 + +### 8.4 回滚 + +- 关闭 `poster_render_v2` 即可停止新链路; +- 新增字段保持 nullable,旧代码可忽略; +- 新导出文件使用新 revision 文件名,不覆盖旧文件; +- 任何阶段不得删除用户已有海报或背景素材。 + +## 9. 监控与诊断信息 + +每次导出至少记录: + +```text +recordId +schemaVersion +formatId +layoutKey +logicalWidth/logicalHeight +requestedWidth/requestedHeight +actualWidth/actualHeight +pixelCount +blobByteSize +backgroundProvider/backgroundModel +backgroundRequestedSize/backgroundActualSize +renderDurationMs +uploadDurationMs +validationResult +failureStage/failureCode +``` + +首发观察指标: + +- 背景生成成功率; +- 最终合成成功率; +- 最终文件验证通过率; +- 下载成功率; +- 各阶段 P50/P95 时长; +- 浏览器导出失败原因分布。 + +## 10. 文件级修改矩阵 + +| 文件/目录 | 计划改动 | 阶段 | +|---|---|---:| +| `api/insurance/poster/format_registry.py` | 统一格式规格与校验 | 1 | +| `api/insurance/poster/render_document_builder.py` | 构建唯一 renderDocument | 2 | +| `api/insurance/poster/content_builder.py` | 修正 case facts/product rules 合并 | 2 | +| `api/insurance/poster/image_generator.py` | asset size 严格映射、无文字 fallback | 1/2 | +| `api/insurance/poster/service.py` | format 校验、document v2、PNG 校验 | 1/2/4 | +| `api/insurance/poster/routes.py` | formats API、明确下载错误 | 1/4 | +| `api/insurance/generation/celery_tasks.py` | 使用 renderDocument 生成背景 | 2 | +| `api/insurance/models/poster_template_model.py` | 最少模板兼容字段 | 3 | +| `api/insurance/db/migrate_0xx.py` | 增量模板字段迁移 | 3 | +| `frontend/src/composables/usePosterWorkspace.ts` | formatId、renderDocument、v1/v2 恢复 | 1/2 | +| `frontend/src/utils/poster-api.ts` | formats 和 v2 document API | 1/2 | +| `frontend/src/utils/poster-exporter.ts` | 独立确定性导出器 | 4 | +| `frontend/src/components/poster/PosterCanvasRouter.vue` | 单图/长图布局路由 | 3 | +| `frontend/src/components/poster/single/` | 单图专用布局 | 3 | +| `frontend/src/components/poster/long/` | 长图布局与主题 token | 3 | +| `frontend/src/components/poster/workspace/PosterCreativePanel.vue` | 兼容格式和模板选择 | 1/3 | +| `frontend/src/components/poster/workspace/PosterStage.vue` | 预览与未缩放画布管理 | 3/4 | +| `frontend/src/pages/PosterPage.vue` | 简化为编排、状态与持久化 | 1/2/4 | +| `tests/poster_*` | 单元、契约、导出验证 | 全阶段 | + +## 11. 明确不做的事项 + +为了控制改动范围,本期不做: + +- 任意自定义宽高; +- A4/横版/方图; +- 所见即所得自由拖拽编辑器; +- 管理员可视化搭建模板; +- 自动生成多页拼版; +- 无真实案例支撑的长图自动分片; +- 在阶段 5 决策前直接引入 Chromium; +- 重构无关 PPT、认证、产品推荐或基座代码。 + +## 12. 最终验收清单 + +只有以下项目全部通过,才能认为修复完成: + +- [ ] 首发仅显示已确认的三种格式; +- [ ] 未知格式不会静默回退; +- [ ] CSS 逻辑尺寸与 PNG 输出尺寸明确分离; +- [ ] single 和 long 使用不同的画布组件与内容预算; +- [ ] 单图没有被 `overflow:hidden` 静默裁掉的内容; +- [ ] 长图内容结束后没有人为 `min-height` 空白; +- [ ] 模板只出现在兼容 mode/format 中; +- [ ] 模板主题贯穿所有章节,不再固定白底绿色; +- [ ] AI 和 fallback 都不绘制任何文案、数字或 Logo; +- [ ] prompt、预览、导出读取同一 renderDocument; +- [ ] 单图 PNG 像素严格符合规格; +- [ ] 长图 PNG 宽度严格符合规格,高度来自真实内容; +- [ ] 图表、字体和图片全部 ready 后才导出; +- [ ] 服务端能拒绝损坏或错误尺寸的 PNG; +- [ ] 导出失败不会覆盖上一版有效成品; +- [ ] 历史记录能正确恢复 v1/v2 文档; +- [ ] 灰度开关与回滚路径验证通过; +- [ ] 是否引入服务端 Headless Chromium 已通过阶段 5 数据决策。 + +## 13. 推荐执行顺序 + +严格按以下顺序实施,不并行修改同一链路: + +```text +复现与规格确认 +→ 格式契约 +→ 统一 renderDocument +→ 单图/长图布局 +→ 确定性导出与服务端校验 +→ 真实可靠性评估 +→ 条件性服务端 Headless +``` + +前一阶段的完成定义未通过,不进入下一阶段。这样可以避免一边改模板、一边改尺寸、一边换渲染器,最终无法定位回归来源。 diff --git a/frontend/src/components/poster/workspace/PosterActionBar.vue b/frontend/src/components/poster/workspace/PosterActionBar.vue index f534c6a..147ef78 100644 --- a/frontend/src/components/poster/workspace/PosterActionBar.vue +++ b/frontend/src/components/poster/workspace/PosterActionBar.vue @@ -75,7 +75,8 @@ defineEmits<{ const isGenerating = computed(() => props.draft.taskStatus === 'submitting' || props.draft.taskStatus === 'queued' || - props.draft.taskStatus === 'generating' + props.draft.taskStatus === 'generating' || + props.draft.taskStatus === 'asset_loading' ) const canAct = computed(() => { diff --git a/frontend/src/components/poster/workspace/PosterStage.vue b/frontend/src/components/poster/workspace/PosterStage.vue index 07ed3ff..e6b1b4c 100644 --- a/frontend/src/components/poster/workspace/PosterStage.vue +++ b/frontend/src/components/poster/workspace/PosterStage.vue @@ -6,28 +6,36 @@ -
- - +
+
+
+ + +
+
-
+

- {{ draft.taskStatus === 'queued' ? '任务排队中...' : '海报生成中,预计 10 到 30 秒' }} + {{ draft.taskStatus === 'queued' + ? '任务排队中...' + : draft.taskStatus === 'asset_loading' + ? '正在加载背景并准备可编辑预览...' + : '海报生成中,预计 10 到 30 秒' }}

@@ -56,7 +64,7 @@