- 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 造数
197 lines
7.4 KiB
Python
197 lines
7.4 KiB
Python
"""形象报告 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
|