baodan/api/insurance/ppt/template_asset_service.py

165 lines
6.1 KiB
Python
Raw Normal View History

"""PPT 模板资产保存、解析与路径解析。"""
from __future__ import annotations
import os
import re
import uuid
2026-07-31 20:05:34 +08:00
import hashlib
BUILTIN_SCHEME = "builtin://"
STORED_SCHEME = "stored://"
MAX_TEMPLATE_BYTES = 30 * 1024 * 1024
BUILTIN_TEMPLATE_ROOT = os.path.join(os.path.dirname(__file__), "template_assets")
2026-07-31 20:05:34 +08:00
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 "")
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"