Files
meida_backend/packages/report/pdf/renderer.py
T
xsl cd0ddb131b feat(report): 报告 PDF 导出管线(Phase A,WeasyPrint 服务端渲染)
- POST /reports/{id}/export:request_key 幂等 + hd_export 日配额三段式(reserve/commit/refund)+ tier2 熔断场景闸(BudgetGuardDep),仅 succeeded 报告可导
- GET /reports/{id}/export/download:归属校验 + 路径收敛(rendered_asset_key 必须落在 report_export_dir 内),FileResponse 携带 UTF-8 文件名
- celery 任务 meida.report.export(cpu 队列):渲染→落盘 var/exports→回写 rendered_asset_key;业务性拒绝(报告状态变化)结案退款不重试,基础设施异常重试一次后兜底结案退款
- packages/report/pdf:Jinja2 模板 + WeasyPrint 渲染器(url_fetcher 全拒外部资源防 SSRF;维度中文名按 DIMENSION_BY_KEY 回填;色板维度渲染色卡)
- 单测 10 例(资格/幂等/并发同键/下载校验/渲染);scripts/seed_pdf_e2e.sql 供本地 E2E 造数
2026-08-30 13:17:23 +08:00

197 lines
7.4 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.
"""形象报告 PDF 渲染器:result JSONB → PDF bytes(无 I/O,纯内存渲染)。
输入是 ``rp_report.result``(diagnosis/copy/validation 三段产物),输出
PDF 字节串;落盘、回写 ``rendered_asset_key`` 归 worker 任务,下载归路由——
渲染器本身只关心「内容 → 版式」,便于单测与将来换排版。
安全约定(WeasyPrint 能加载外部资源,等于一个 SSRF 面):
模板不引用任何外部 URL,且自定义 url_fetcher 对**一切** URL(含 file://)
直接拒绝——未来模板误加 ``<img src>`` 也只是渲染失败,绝不会把本地文件
或内网资源打进 PDF。
维度中文名不在 ``diagnosis.dimensions`` 行内(DimensionResult 只有 key),
渲染时按 key 回填 ``DIMENSION_BY_KEY`` 的字典定义——PDF 展示口径与流水线
产物口径各管各的,产物不冗余存展示字段。
"""
from __future__ import annotations
import json
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any, NoReturn
import jinja2
from weasyprint import HTML
from packages.report.dimensions import DIMENSION_BY_KEY
from packages.shared.errors import AppError, ErrorCode
_TEMPLATE_DIR = Path(__file__).parent / "templates"
#: 档案摘要页「核心结论」卡片的维度键:体/脸/季 + 风格三件套是报告的画像锚点
#: (与 copy.py HARD_DIMENSION_KEYS 的取舍同源——这些是用户最先看的结论)。
_CORE_DIM_KEYS: tuple[str, ...] = (
"body_shape",
"face_shape",
"skin_tone",
"season_code",
"gene_style",
"psyche_style",
"fusion_style_name",
"core_goals",
)
#: 值里可能带十六进制色、值得渲染成色卡的维度键。
_SWATCH_DIM_KEYS: frozenset[str] = frozenset(
{"best_colors", "neutral_colors", "avoid_colors", "hair_color"}
)
_env = jinja2.Environment(
loader=jinja2.FileSystemLoader(_TEMPLATE_DIR),
autoescape=jinja2.select_autoescape(default=True, default_for_string=True),
trim_blocks=True,
lstrip_blocks=True,
)
@dataclass(frozen=True, slots=True)
class ReportPdfMeta:
"""渲染元信息(报告主体之外的归属字段,由 worker 从 rp_report 行带入)。"""
report_id: int
version: int
generated_at: datetime
brand: str = "美搭"
title: str = "个人形象美学报告"
@dataclass(frozen=True, slots=True)
class _DimView:
"""单维度的展示模型:模板保持哑渲染,格式化全部在 Python 侧完成。"""
number: int
key: str
name_cn: str
value_text: str
confidence_pct: int | None # skipped 维度无置信度
evidence: str
skipped: bool
swatches: tuple[dict[str, str], ...] = field(default=())
def _reject_all_urls(url: str, **_options: Any) -> NoReturn:
"""WeasyPrint url_fetcher:本版模板零外部资源,任何拉取都是 bug 或注入。"""
raise ValueError(f"PDF 模板不允许加载外部资源:{url!r}")
def _fmt_scalar(value: Any) -> str:
if value is None:
return "—"
if isinstance(value, bool):
return "是" if value else "否"
if isinstance(value, str):
return value
if isinstance(value, int | float):
return str(value)
return json.dumps(value, ensure_ascii=False)
def _fmt_value(value: Any) -> str:
"""维度结论值 → 一行展示文本(枚举/数值/列表/字典都可能出现)。"""
if isinstance(value, list):
parts = []
for item in value:
if isinstance(item, Mapping):
# 色卡类条目优先用中文名,其次 hex;其余字典整体 JSON 化。
parts.append(str(item.get("name_cn") or item.get("hex") or _fmt_scalar(dict(item))))
else:
parts.append(_fmt_scalar(item))
return "、".join(parts) if parts else "—"
return _fmt_scalar(value)
def _extract_swatches(key: str, value: Any) -> tuple[dict[str, str], ...]:
"""色卡维度提取 ``{hex, name}`` 列表;非色卡维度或值形不符时为空。"""
if key not in _SWATCH_DIM_KEYS or not isinstance(value, list):
return ()
swatches: list[dict[str, str]] = []
for item in value:
if isinstance(item, Mapping) and isinstance(item.get("hex"), str):
swatches.append({"hex": item["hex"], "name": str(item.get("name_cn") or "")})
return tuple(swatches)
def _build_dimensions(diagnosis: Mapping[str, Any]) -> list[_DimView]:
raw_dims = diagnosis.get("dimensions") or []
views: list[_DimView] = []
for raw in raw_dims:
if not isinstance(raw, Mapping):
continue
key = str(raw.get("key") or "")
spec = DIMENSION_BY_KEY.get(key)
skipped = raw.get("status") == "skipped"
value = raw.get("value")
confidence = raw.get("confidence")
views.append(
_DimView(
number=int(raw.get("number") or (spec.number if spec else 0)),
key=key,
name_cn=spec.name_cn if spec else key or "未知维度",
value_text="初级版未覆盖" if skipped else _fmt_value(value),
confidence_pct=None
if skipped or not isinstance(confidence, int | float)
else round(float(confidence) * 100),
evidence=str(raw.get("evidence") or ""),
skipped=skipped,
swatches=() if skipped else _extract_swatches(key, value),
)
)
views.sort(key=lambda view: view.number)
return views
def render_report_pdf(result: Mapping[str, Any], *, meta: ReportPdfMeta) -> bytes:
"""渲染一份形象报告 PDF;产物结构不完整即 40900(内容由流水线保证)。
只对 ``succeeded`` 报告调用(路由/worker 已把关),这里仍校验 copy
存在——渲染器不知道上游纪律,结构缺口必须在自己手里变成结构化错误,
而不是 Jinja 渲染一半的 500。
"""
copy = result.get("copy")
diagnosis = result.get("diagnosis")
if not isinstance(copy, Mapping) or not isinstance(diagnosis, Mapping):
raise AppError(ErrorCode.STATE_CONFLICT, message="报告内容不完整,无法导出 PDF")
summary = str(copy.get("summary") or "")
sections: list[dict[str, Any]] = []
for section in copy.get("sections") or []:
if not isinstance(section, Mapping):
continue
sections.append(
{
"title": str(section.get("title") or ""),
"paragraphs": [str(p) for p in section.get("paragraphs") or []],
}
)
dimensions = _build_dimensions(diagnosis)
# 核心卡片按字典键集合挑,保持档案摘要页聚焦画像锚点
core_keys = set(_CORE_DIM_KEYS)
core_views = [v for v in dimensions if v.key in core_keys and not v.skipped]
template = _env.get_template("report.html.j2")
html = template.render(
meta=meta,
date_text=meta.generated_at.strftime("%Y年%m月%d日"),
summary=summary,
sections=sections,
dimensions=dimensions,
core_dimensions=core_views,
ok_count=sum(1 for v in dimensions if not v.skipped),
)
# weasyprint 的 write_pdf 声明返回 Any(其 typeshed 未收口),按声明类型收窄
pdf: bytes = HTML(string=html, base_url=None, url_fetcher=_reject_all_urls).write_pdf()
return pdf