baodan/api/insurance/ppt/template_asset_service.py
wsb1224 f23b08eec6 07-29 PPT生成优化第一阶段完成:
已完成约 40%(阶段 1 + 阶段 2 + 阶段 3 基础编辑),覆盖了计划书中最核心的 P0 需求:

 真实预览(Canvas 方案替代了 LibreOffice 方案)
 质量检查(6 自动 + 4 人工)
 三栏结果工作台
 基础文字编辑
2026-07-29 21:26:48 +08:00

155 lines
5.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""PPT 模板资产保存、解析与路径解析。"""
from __future__ import annotations
import os
import re
import uuid
BUILTIN_SCHEME = "builtin://"
STORED_SCHEME = "stored://"
MAX_TEMPLATE_BYTES = 30 * 1024 * 1024
BUILTIN_TEMPLATE_ROOT = os.path.join(os.path.dirname(__file__), "template_assets")
def resolve_template_asset(asset_id: str | None) -> str | None:
"""将数据库中的模板资产标识解析为受控的本地绝对路径。"""
value = str(asset_id or "")
if value.startswith(BUILTIN_SCHEME):
return _safe_join(BUILTIN_TEMPLATE_ROOT, value[len(BUILTIN_SCHEME):])
if value.startswith(STORED_SCHEME):
from insurance.config import get_storage_root
root = os.path.join(get_storage_root(), "ppt_templates")
return _safe_join(root, value[len(STORED_SCHEME):])
return None
def save_uploaded_template(file_storage, template_id: str) -> tuple[str, str]:
"""校验并保存上传的 PPTX返回资产标识和绝对路径。"""
if not file_storage or not file_storage.filename:
raise ValueError("请选择 PPTX 模板文件")
if not file_storage.filename.lower().endswith(".pptx"):
raise ValueError("仅支持 .pptx 格式")
stream = file_storage.stream
stream.seek(0, os.SEEK_END)
size = stream.tell()
stream.seek(0)
if size <= 0:
raise ValueError("模板文件为空")
if size > MAX_TEMPLATE_BYTES:
raise ValueError("模板文件不能超过 30MB")
safe_id = re.sub(r"[^a-zA-Z0-9_-]", "_", template_id)[:50] or "template"
relative_path = f"{safe_id}/{uuid.uuid4().hex}.pptx"
from insurance.config import get_storage_root
root = os.path.join(get_storage_root(), "ppt_templates")
absolute_path = _safe_join(root, relative_path)
os.makedirs(os.path.dirname(absolute_path), exist_ok=True)
file_storage.save(absolute_path)
return f"{STORED_SCHEME}{relative_path.replace(os.sep, '/')}", absolute_path
def parse_template_pptx(path: str) -> dict:
"""提取幻灯片标题、页面类型和基础版式信息。"""
try:
from pptx import Presentation
except ImportError as exc:
raise ValueError("服务端缺少 PPTX 解析组件") from exc
try:
presentation = Presentation(path)
except Exception as exc:
raise ValueError(f"PPTX 文件无法解析:{exc}") from exc
slides = []
total = len(presentation.slides)
for index, slide in enumerate(presentation.slides):
title = _extract_slide_title(slide) or f"{index + 1}"
slides.append({
"pageType": _infer_page_type(slide, title, index, total),
"title": title[:120],
"narrativeHint": "由源 PPTX 自动解析",
"sourceSlide": index + 1,
})
if not slides:
raise ValueError("模板中没有可用幻灯片")
return {
"slideCount": total,
"width": round(presentation.slide_width / 914400, 2),
"height": round(presentation.slide_height / 914400, 2),
"slidesConfig": slides,
"requiredPageTypes": [slide["pageType"] for slide in slides],
}
def delete_stored_template(asset_id: str | None) -> None:
"""删除管理员上传的旧资产;内置模板永不删除。"""
if not str(asset_id or "").startswith(STORED_SCHEME):
return
path = resolve_template_asset(asset_id)
if path and os.path.isfile(path):
try:
os.remove(path)
except OSError:
pass
def _safe_join(root: str, relative_path: str) -> str:
normalized_root = os.path.abspath(root)
target = os.path.abspath(os.path.join(normalized_root, relative_path))
try:
if os.path.commonpath([normalized_root, target]) != normalized_root:
raise ValueError("模板资产路径非法")
except ValueError as exc:
raise ValueError("模板资产路径非法") from exc
return target
def _extract_slide_title(slide) -> str:
title_shape = slide.shapes.title
if title_shape is not None and title_shape.has_text_frame:
value = title_shape.text.strip()
if value:
return value
for shape in slide.shapes:
if getattr(shape, "has_text_frame", False):
value = shape.text.strip()
if value:
return value.splitlines()[0].strip()
return ""
def _infer_page_type(slide, title: str, index: int, total: int) -> str:
text = title.lower()
if index == 0:
return "cover"
if index == total - 1 or any(word in text for word in ("感谢", "下一步", "thank")):
return "closing"
if "组合" in text and "摘要" in text:
return "combined_summary"
if "摘要" in text:
return "policy_summary"
if any(word in text for word in ("对应表", "年度对应", "计划书对应")):
return "alignment_table"
if any(word in text for word in ("启动路径", "两种路径")):
return "launch_paths"
if any(word in text for word in ("承接", "现金流接力")):
return "cashflow_bridge"
if any(word in text for word in ("时间轴", "时间表", "阶段")):
return "timeline"
if any(word in text for word in ("保司", "公司介绍", "公司实力")):
return "company"
if any(getattr(shape, "has_chart", False) for shape in slide.shapes):
return "comparison_chart" if "对比" in text else "chart"
if any(getattr(shape, "has_table", False) for shape in slide.shapes):
return "comparison_table" if "对比" in text else "table"
if any(word in text for word in ("对比", "比较")):
return "compare"
if any(word in text for word in ("组合", "协同", "分工")):
return "synergy"
if any(word in text for word in ("结论", "建议", "适合")):
return "conclusion"
return "guidance"