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 造数
This commit is contained in:
@@ -192,8 +192,7 @@ def build_rule_context(
|
||||
features=tuple(_str_list(derived_features.get("body_features"))),
|
||||
goals=tuple(_str_list(questionnaire.get("core_goals"))),
|
||||
occasion=occasion,
|
||||
style=_str(questionnaire.get("psyche_style"))
|
||||
or _str(questionnaire.get("psych_style")),
|
||||
style=_str(questionnaire.get("psyche_style")) or _str(questionnaire.get("psych_style")),
|
||||
user_taboos=tuple(_str_list(questionnaire.get("taboos"))),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
"""报告 PDF 导出的域服务(Phase A):资格、幂等、配额预扣与任务投递。
|
||||
|
||||
与报告生成的差别:导出是「读产物 → 渲染 → 落盘」的纯加工,不重算内容,
|
||||
因此不建新报告行,只建 tk_task(type=render)并在成功后回写
|
||||
``rp_report.rendered_asset_key``。
|
||||
|
||||
事务纪律与 ReportService 一致:reserve 与建任务同事务,commit 之后才投递;
|
||||
幂等同样是双保险——tk_task 幂等键挡重复任务行,reserve 幂等键挡重复预扣,
|
||||
服务层重放快捷路径让同键重试拿到 200。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from packages.billing.service import BillingService
|
||||
from packages.domain.task.state import TaskStatus, TaskType
|
||||
from packages.report import repo
|
||||
from packages.shared.config import Settings
|
||||
from packages.shared.errors import AppError, ErrorCode, ForbiddenError
|
||||
from packages.shared.logging import get_logger
|
||||
from packages.shared.models import RpReport, Task
|
||||
from packages.task import repo as task_repo
|
||||
from packages.task.service import TaskService
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
#: 导出走 hd_export 日配额(迁移 0003 种子:day / base 1 / hard_cap 3);
|
||||
#: 同一字符串也是熔断档位拦截的场景名(tier2 起被 block,见架构 §13.3)。
|
||||
QUOTA_KEY_EXPORT = "hd_export"
|
||||
EXPORT_REF_TYPE = "report_export"
|
||||
EXPORT_ETA_SECONDS = 30
|
||||
|
||||
_TERMINAL_TASK_STATUSES = frozenset(
|
||||
{TaskStatus.SUCCEEDED.value, TaskStatus.FAILED.value, TaskStatus.CANCELED.value}
|
||||
)
|
||||
|
||||
|
||||
class SceneGuard(Protocol):
|
||||
"""熔断场景闸的最小接口(DailyBudgetGuard 结构匹配)。
|
||||
|
||||
声明 Protocol 而非直接依赖 packages.llm.budget:单测可以塞假实现,
|
||||
服务单测不必拉起 Redis。
|
||||
"""
|
||||
|
||||
async def ensure_scene_allowed(self, *, scene: str, critical: bool = False) -> None: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ExportSubmission:
|
||||
"""一次导出提交的结果;幂等重放返回同值。"""
|
||||
|
||||
task_id: int
|
||||
report_id: int
|
||||
status: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ExportFile:
|
||||
"""下载就绪的导出文件:磁盘路径 + 给客户端的展示文件名。"""
|
||||
|
||||
path: Path
|
||||
filename: str
|
||||
|
||||
|
||||
def _submission_of(task: Task) -> ExportSubmission:
|
||||
"""从 tk_task 行重建导出提交结果(幂等重放路径)。"""
|
||||
raw_report_id = task.payload.get("report_id")
|
||||
if not isinstance(raw_report_id, str | int):
|
||||
raise AppError(ErrorCode.IDEMPOTENCY_CONFLICT)
|
||||
return ExportSubmission(task_id=task.task_id, report_id=int(raw_report_id), status=task.status)
|
||||
|
||||
|
||||
def _resolve_export_path(export_dir: str, asset_key: str) -> Path:
|
||||
"""同步解析导出文件路径(pathlib 的阻塞方法不进 async 函数,ASYNC240)。
|
||||
|
||||
key 由 worker 写入,越界即数据被篡改——按服务器错误处理而非 404;
|
||||
行已回写但文件丢了(磁盘清理等),对用户来说等于还没生成。
|
||||
"""
|
||||
export_root = Path(export_dir).resolve()
|
||||
path = Path(asset_key).resolve()
|
||||
if not path.is_relative_to(export_root):
|
||||
raise AppError(ErrorCode.INTERNAL_ERROR, message="导出文件路径异常")
|
||||
if not path.is_file():
|
||||
raise AppError(ErrorCode.REPORT_EXPORT_NOT_READY)
|
||||
return path
|
||||
|
||||
|
||||
class ReportExportService:
|
||||
def __init__(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
settings: Settings,
|
||||
scene_guard: SceneGuard | None = None,
|
||||
enqueue: Callable[[int], None] | None = None,
|
||||
) -> None:
|
||||
self._session = session
|
||||
self._settings = settings
|
||||
# scene_guard 为 None 时跳过熔断(单测直调);API 装配层总是注入真实现。
|
||||
self._scene_guard = scene_guard
|
||||
self._enqueue = enqueue
|
||||
self._billing = BillingService(
|
||||
session, tz_offset_hours=settings.llm_budget_timezone_offset_hours
|
||||
)
|
||||
self._tasks = TaskService(session)
|
||||
|
||||
# -- 发起导出 ------------------------------------------------------------
|
||||
|
||||
async def create_export(
|
||||
self, *, user_id: int, report_id: int, request_key: str
|
||||
) -> ExportSubmission:
|
||||
"""预扣 hd_export + 建 render 任务 + commit 后投递;同 request_key 重放返回同一任务。"""
|
||||
replay = await self._find_replay(
|
||||
request_key=request_key, user_id=user_id, report_id=report_id
|
||||
)
|
||||
if replay is not None:
|
||||
return replay
|
||||
|
||||
report = await self.get_report(user_id=user_id, report_id=report_id)
|
||||
if report.status != repo.STATUS_SUCCEEDED:
|
||||
raise AppError(ErrorCode.STATE_CONFLICT, message="只有已完成的报告可以导出 PDF")
|
||||
|
||||
# 熔断先于预扣:档位拦截时用户额度原样保留(架构 §13.3)。
|
||||
if self._scene_guard is not None:
|
||||
await self._scene_guard.ensure_scene_allowed(scene=QUOTA_KEY_EXPORT)
|
||||
|
||||
reservation = await self._billing.reserve(
|
||||
user_id=user_id,
|
||||
quota_key=QUOTA_KEY_EXPORT,
|
||||
request_key=request_key,
|
||||
amount=1,
|
||||
reason=f"export report v{report.version} pdf",
|
||||
ref_type=EXPORT_REF_TYPE,
|
||||
ref_id=str(report.report_id),
|
||||
)
|
||||
task = await self._tasks.submit(
|
||||
user_id=user_id,
|
||||
task_type=TaskType.RENDER,
|
||||
idempotency_key=request_key,
|
||||
payload={"report_id": str(report.report_id), "version": report.version},
|
||||
quota_reserved=reservation.amount,
|
||||
reserve_tx_id=reservation.tx_id,
|
||||
eta_seconds=EXPORT_ETA_SECONDS,
|
||||
)
|
||||
if task.payload.get("report_id") != str(report.report_id):
|
||||
# 并发同键:tk_task 唯一键已被先到者占住,回滚自己的预扣,按先到者返回。
|
||||
await self._session.rollback()
|
||||
winner = await task_repo.find_by_idempotency_key(self._session, request_key)
|
||||
if winner is None: # pragma: no cover - 唯一键刚被对手占住,不可达兜底
|
||||
raise AppError(ErrorCode.IDEMPOTENCY_CONFLICT)
|
||||
return _submission_of(winner)
|
||||
|
||||
# 先提交再投递:worker 收到消息时任务行必须已经可见。
|
||||
await self._session.commit()
|
||||
if self._enqueue is not None and task.status not in _TERMINAL_TASK_STATUSES:
|
||||
self._enqueue(task.task_id)
|
||||
logger.info(
|
||||
"report_export_created",
|
||||
user_id=user_id,
|
||||
report_id=report_id,
|
||||
task_id=task.task_id,
|
||||
)
|
||||
return ExportSubmission(task.task_id, report.report_id, task.status)
|
||||
|
||||
# -- 下载 -----------------------------------------------------------------
|
||||
|
||||
async def get_export_file(self, *, user_id: int, report_id: int) -> ExportFile:
|
||||
"""已生成校验 + 路径收敛:rendered_asset_key 只允许落在导出目录内。"""
|
||||
report = await self.get_report(user_id=user_id, report_id=report_id)
|
||||
if not report.rendered_asset_key:
|
||||
raise AppError(ErrorCode.REPORT_EXPORT_NOT_READY)
|
||||
path = _resolve_export_path(self._settings.report_export_dir, report.rendered_asset_key)
|
||||
return ExportFile(path=path, filename=f"美搭形象报告-v{report.version}.pdf")
|
||||
|
||||
# -- 查询 -----------------------------------------------------------------
|
||||
|
||||
async def get_report(self, *, user_id: int, report_id: int) -> RpReport:
|
||||
report = await repo.get_report(self._session, report_id)
|
||||
if report is None:
|
||||
raise AppError(ErrorCode.REPORT_NOT_FOUND)
|
||||
if report.user_id != user_id:
|
||||
# 归属校验(架构 §11.1):不能只靠 ID 不可猜,必须显式比对。
|
||||
raise ForbiddenError()
|
||||
return report
|
||||
|
||||
# -- 内部 -----------------------------------------------------------------
|
||||
|
||||
async def _find_replay(
|
||||
self, *, request_key: str, user_id: int, report_id: int
|
||||
) -> ExportSubmission | None:
|
||||
"""同 request_key 的 RENDER 任务已存在时重建提交结果。"""
|
||||
existing = await task_repo.find_by_idempotency_key(self._session, request_key)
|
||||
if existing is None:
|
||||
return None
|
||||
if existing.user_id != user_id or existing.task_type != TaskType.RENDER.value:
|
||||
# 幂等键全局唯一,撞上了其它用户/其它功能的关键字:按幂等冲突拒绝。
|
||||
raise AppError(ErrorCode.IDEMPOTENCY_CONFLICT)
|
||||
submission = _submission_of(existing)
|
||||
if submission.report_id != report_id:
|
||||
# 同键不同报告:不是同一次请求的重放。
|
||||
raise AppError(ErrorCode.IDEMPOTENCY_CONFLICT)
|
||||
return submission
|
||||
@@ -0,0 +1,7 @@
|
||||
"""报告 PDF 渲染(Phase A):Jinja2 HTML 模板 + WeasyPrint。
|
||||
|
||||
为什么偏离 TD-12 的 Playwright 方案:本版报告的勾选行 / 色卡 / 置信度条
|
||||
用 CSS Paged Media 足以表达,而本环境拉取 Chromium(Microsoft CDN)困难;
|
||||
WeasyPrint 纯 Python + Pango,无浏览器依赖、渲染确定性好。代价是复杂
|
||||
长图式交互布局表达力弱——Phase B 若要像素级复刻参考长图再评估切回。
|
||||
"""
|
||||
@@ -0,0 +1,196 @@
|
||||
"""形象报告 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
|
||||
@@ -0,0 +1,254 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{ meta.brand }} · {{ meta.title }}</title>
|
||||
<style>
|
||||
/* ── 页面与页脚:A4 分页,页脚带 AI 标识 + 免责语(合规 §合规与安全方案)── */
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 18mm 16mm 22mm 16mm;
|
||||
@bottom-center {
|
||||
content: "AI 生成内容,仅供参考 · 本报告由人工智能生成,不构成专业形象顾问意见 · 第 " counter(page) " / " counter(pages) " 页";
|
||||
font-size: 7.5pt;
|
||||
color: #9a8f8a;
|
||||
}
|
||||
}
|
||||
@page cover {
|
||||
/* 封面不显示页脚页码,保持仪式感 */
|
||||
@bottom-center { content: none; }
|
||||
}
|
||||
|
||||
:root {
|
||||
--accent: #a03d5a;
|
||||
--accent-soft: #f6ecef;
|
||||
--ink: #2b2624;
|
||||
--muted: #8a807b;
|
||||
--line: #e4dbd6;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: 'Noto Sans CJK SC', sans-serif;
|
||||
color: var(--ink);
|
||||
font-size: 10pt;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
/* ── 每页水印:fixed 元素在 WeasyPrint 里逐页重复 ─────────────────────── */
|
||||
.watermark {
|
||||
position: fixed;
|
||||
top: 42%;
|
||||
left: 8%;
|
||||
width: 84%;
|
||||
text-align: center;
|
||||
transform: rotate(-28deg);
|
||||
font-size: 52pt;
|
||||
font-weight: bold;
|
||||
letter-spacing: 12pt;
|
||||
color: rgba(160, 61, 90, 0.07);
|
||||
}
|
||||
|
||||
/* ── 封面 ─────────────────────────────────────────────────────────────── */
|
||||
.cover {
|
||||
page: cover;
|
||||
page-break-after: always;
|
||||
height: 240mm;
|
||||
text-align: center;
|
||||
padding-top: 62mm;
|
||||
}
|
||||
.cover .brand {
|
||||
font-size: 15pt;
|
||||
letter-spacing: 10pt;
|
||||
color: var(--accent);
|
||||
margin-bottom: 6mm;
|
||||
}
|
||||
.cover h1 {
|
||||
font-size: 30pt;
|
||||
font-weight: bold;
|
||||
letter-spacing: 4pt;
|
||||
margin: 0 0 10mm 0;
|
||||
}
|
||||
.cover .rule {
|
||||
width: 42mm;
|
||||
border-top: 1.2pt solid var(--accent);
|
||||
margin: 0 auto 10mm auto;
|
||||
}
|
||||
.cover .date { color: var(--muted); font-size: 11pt; letter-spacing: 2pt; }
|
||||
.cover .version { color: var(--muted); font-size: 9pt; margin-top: 2mm; }
|
||||
.cover .ai-badge {
|
||||
display: inline-block;
|
||||
margin-top: 52mm;
|
||||
padding: 2.5mm 8mm;
|
||||
border: 1pt solid var(--accent);
|
||||
border-radius: 12mm;
|
||||
color: var(--accent);
|
||||
font-size: 10pt;
|
||||
letter-spacing: 3pt;
|
||||
}
|
||||
|
||||
/* ── 章节 ─────────────────────────────────────────────────────────────── */
|
||||
h2.chapter {
|
||||
font-size: 15pt;
|
||||
color: var(--accent);
|
||||
border-left: 3.5pt solid var(--accent);
|
||||
padding-left: 4mm;
|
||||
margin: 9mm 0 4mm 0;
|
||||
page-break-after: avoid;
|
||||
}
|
||||
h3.sec-title {
|
||||
font-size: 12pt;
|
||||
margin: 7mm 0 2.5mm 0;
|
||||
page-break-after: avoid;
|
||||
}
|
||||
p.para { margin: 0 0 3mm 0; text-align: justify; }
|
||||
|
||||
.summary-box {
|
||||
background: var(--accent-soft);
|
||||
border-radius: 3mm;
|
||||
padding: 5mm 6mm;
|
||||
margin: 4mm 0 2mm 0;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
/* ── 核心结论卡片(档案摘要页)────────────────────────────────────────── */
|
||||
.core-grid { width: 100%; border-collapse: separate; border-spacing: 2.5mm; }
|
||||
.core-card {
|
||||
background: #fbf8f7;
|
||||
border: 0.6pt solid var(--line);
|
||||
border-radius: 2.5mm;
|
||||
padding: 3.5mm 4mm;
|
||||
width: 50%;
|
||||
vertical-align: top;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
.core-card .dim-name { font-size: 8.5pt; color: var(--muted); letter-spacing: 1pt; }
|
||||
.core-card .dim-value { font-size: 11.5pt; font-weight: bold; margin: 1mm 0; }
|
||||
.core-card .dim-value::before { content: "✓ "; color: var(--accent); }
|
||||
|
||||
/* ── 置信度条 ─────────────────────────────────────────────────────────── */
|
||||
.conf { background: #eee4e0; border-radius: 2mm; height: 2.2mm; width: 26mm; display: inline-block; vertical-align: middle; }
|
||||
.conf .fill { background: var(--accent); border-radius: 2mm; height: 2.2mm; }
|
||||
.conf-num { font-size: 8pt; color: var(--muted); margin-left: 1.5mm; }
|
||||
|
||||
/* ── 色卡 ─────────────────────────────────────────────────────────────── */
|
||||
.swatch { display: inline-block; width: 4.5mm; height: 4.5mm; border-radius: 1mm;
|
||||
border: 0.4pt solid #c9bcb6; margin-right: 1mm; vertical-align: middle; }
|
||||
.swatch-name { font-size: 8pt; color: var(--muted); margin-right: 2.5mm; }
|
||||
|
||||
/* ── 32 维明细表 ──────────────────────────────────────────────────────── */
|
||||
table.dims { width: 100%; border-collapse: collapse; font-size: 8.5pt; margin-top: 3mm; }
|
||||
table.dims th {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-weight: normal;
|
||||
padding: 2mm 2.5mm;
|
||||
text-align: left;
|
||||
}
|
||||
table.dims td {
|
||||
border-bottom: 0.5pt solid var(--line);
|
||||
padding: 2mm 2.5mm;
|
||||
vertical-align: top;
|
||||
}
|
||||
table.dims tr { page-break-inside: avoid; }
|
||||
table.dims tr.skipped td { color: #b3a9a4; background: #faf8f7; }
|
||||
.dim-no { color: var(--muted); width: 8mm; }
|
||||
.dim-name { width: 24mm; font-weight: bold; }
|
||||
.dim-evidence { color: var(--muted); font-size: 8pt; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="watermark">AI 生成内容</div>
|
||||
|
||||
<!-- ── 封面 ─────────────────────────────────────────────────────────── -->
|
||||
<section class="cover">
|
||||
<div class="brand">{{ meta.brand }}</div>
|
||||
<h1>{{ meta.title }}</h1>
|
||||
<div class="rule"></div>
|
||||
<div class="date">{{ date_text }}</div>
|
||||
<div class="version">报告编号 {{ meta.report_id }} · 第 {{ meta.version }} 版</div>
|
||||
<div class="ai-badge">AI 生成内容</div>
|
||||
</section>
|
||||
|
||||
<!-- ── 档案摘要 ───────────────────────────────────────────────────────── -->
|
||||
<section>
|
||||
<h2 class="chapter">档案摘要</h2>
|
||||
{% if summary %}
|
||||
<div class="summary-box">
|
||||
<p class="para" style="margin:0">{{ summary }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if core_dimensions %}
|
||||
<table class="core-grid">
|
||||
{% for pair in core_dimensions | batch(2) %}
|
||||
<tr>
|
||||
{% for dim in pair %}
|
||||
<td class="core-card">
|
||||
<div class="dim-name">{{ dim.name_cn }}</div>
|
||||
<div class="dim-value">{{ dim.value_text }}</div>
|
||||
{% if dim.confidence_pct is not none %}
|
||||
<span class="conf"><span class="fill" style="width: {{ dim.confidence_pct }}%"></span></span>
|
||||
<span class="conf-num">置信度 {{ dim.confidence_pct }}%</span>
|
||||
{% endif %}
|
||||
{% for sw in dim.swatches %}
|
||||
<span class="swatch" style="background: {{ sw.hex }}"></span><span class="swatch-name">{{ sw.name }}</span>
|
||||
{% endfor %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<!-- ── 正文(copy.sections 全文渲染)───────────────────────────────────── -->
|
||||
{% if sections %}
|
||||
<section>
|
||||
<h2 class="chapter">专属解读</h2>
|
||||
{% for section in sections %}
|
||||
<h3 class="sec-title">{{ loop.index }}. {{ section.title }}</h3>
|
||||
{% for para in section.paragraphs %}
|
||||
<p class="para">{{ para }}</p>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<!-- ── 32 维诊断明细 ──────────────────────────────────────────────────── -->
|
||||
{% if dimensions %}
|
||||
<section>
|
||||
<h2 class="chapter">诊断明细({{ ok_count }}/{{ dimensions | length }} 项已覆盖)</h2>
|
||||
<table class="dims">
|
||||
<thead>
|
||||
<tr><th class="dim-no">#</th><th class="dim-name">维度</th><th>结论</th><th style="width:30mm">置信度</th><th style="width:52mm">依据</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for dim in dimensions %}
|
||||
<tr class="{{ 'skipped' if dim.skipped else '' }}">
|
||||
<td class="dim-no">{{ dim.number }}</td>
|
||||
<td class="dim-name">{{ dim.name_cn }}</td>
|
||||
<td>
|
||||
{{ dim.value_text }}
|
||||
{% for sw in dim.swatches %}
|
||||
<span class="swatch" style="background: {{ sw.hex }}"></span><span class="swatch-name">{{ sw.name }}</span>
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td>
|
||||
{% if dim.confidence_pct is not none %}
|
||||
<span class="conf"><span class="fill" style="width: {{ dim.confidence_pct }}%"></span></span>
|
||||
<span class="conf-num">{{ dim.confidence_pct }}%</span>
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="dim-evidence">{{ dim.evidence }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -29,6 +29,17 @@ class ReportRegenerateRequest(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class ReportExportRequest(BaseModel):
|
||||
request_key: str | None = Field(
|
||||
default=None, max_length=128, description="幂等键;相同键返回同一导出任务且不重复扣配额"
|
||||
)
|
||||
|
||||
|
||||
class ReportExportResponse(BaseModel):
|
||||
task_id: str
|
||||
status: str = Field(description="tk_task 状态;导出完成后任务中心可取到 asset_key")
|
||||
|
||||
|
||||
class ReportSubmitResponse(BaseModel):
|
||||
task_id: str
|
||||
report_id: str
|
||||
|
||||
@@ -230,6 +230,9 @@ class Settings(BaseSettings):
|
||||
# 是 Sprint 1 结论落地后的替换项,接口位见 packages/report/vision.py)。
|
||||
report_vision_provider: str = "deterministic"
|
||||
report_tier: str = "basic"
|
||||
# PDF 导出落盘目录(相对进程 cwd;worker 与 API 同 cwd 启动,两边解析一致)。
|
||||
# rp_report.rendered_asset_key 存的是含该目录前缀的相对路径。
|
||||
report_export_dir: str = "var/exports"
|
||||
|
||||
# --- Observability -----------------------------------------------------
|
||||
otel_enabled: bool = False
|
||||
|
||||
@@ -42,6 +42,7 @@ class ErrorCode(IntEnum):
|
||||
NOT_FOUND = 40400
|
||||
TASK_BONUS_UNKNOWN = 40410
|
||||
REPORT_NOT_FOUND = 40420
|
||||
REPORT_EXPORT_NOT_READY = 40421
|
||||
STATE_CONFLICT = 40900
|
||||
TASK_BONUS_ALREADY_CLAIMED = 40910
|
||||
REPORT_ALREADY_EXISTS = 40920
|
||||
@@ -103,6 +104,7 @@ ERROR_META: dict[ErrorCode, ErrorMeta] = {
|
||||
ErrorCode.NOT_FOUND: ErrorMeta(404, "内容不存在或已删除"),
|
||||
ErrorCode.TASK_BONUS_UNKNOWN: ErrorMeta(404, "任务不存在或暂未开放"),
|
||||
ErrorCode.REPORT_NOT_FOUND: ErrorMeta(404, "报告不存在或已删除"),
|
||||
ErrorCode.REPORT_EXPORT_NOT_READY: ErrorMeta(404, "PDF 尚未生成", "请先在报告详情页发起导出"),
|
||||
ErrorCode.STATE_CONFLICT: ErrorMeta(409, "当前状态无法执行该操作"),
|
||||
ErrorCode.TASK_BONUS_ALREADY_CLAIMED: ErrorMeta(
|
||||
409, "这个周期的任务奖励已领取", "下个周期再来"
|
||||
|
||||
Reference in New Issue
Block a user