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:
xsl
2026-08-30 13:17:23 +08:00
parent cb8a1e3c0f
commit cd0ddb131b
17 changed files with 1507 additions and 5 deletions
+1 -2
View File
@@ -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"))),
)
+208
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
"""报告 PDF 渲染(Phase A):Jinja2 HTML 模板 + WeasyPrint。
为什么偏离 TD-12 的 Playwright 方案:本版报告的勾选行 / 色卡 / 置信度条
用 CSS Paged Media 足以表达,而本环境拉取 Chromium(Microsoft CDN)困难;
WeasyPrint 纯 Python + Pango,无浏览器依赖、渲染确定性好。代价是复杂
长图式交互布局表达力弱——Phase B 若要像素级复刻参考长图再评估切回。
"""
+196
View File
@@ -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>
+11
View File
@@ -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
+3
View File
@@ -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
+2
View File
@@ -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, "这个周期的任务奖励已领取", "下个周期再来"