fix(report): 错误 hint 去除「任务中心」指向 + PDF 元数据 AI 生成标识
CI / lint / typecheck / import-linter / tests / secrets (push) Canceled after 0s

- errors.py:IDEMPOTENCY_CONFLICT / TASK_STATE_INVALID 的 hint 改为「首页报告」口径(App 无任务中心页,经 hint 透出会误导)
- report.html.j2:head 增加 author/description/generator meta(WeasyPrint 落入 PDF 属性,AI 生成内容标识 CR-05)
- test_report_export.py:+2 单测(模板 meta 渲染 + PDF 产物 Info 字典解压断言,不新增依赖)

多 agent 评审 R3/R4 产物;单测 576 passed
This commit is contained in:
xsl
2026-08-30 23:17:00 +08:00
parent cd0ddb131b
commit 00ef56b08b
3 changed files with 65 additions and 2 deletions
@@ -3,6 +3,11 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>{{ meta.brand }} · {{ meta.title }}</title> <title>{{ meta.brand }} · {{ meta.title }}</title>
<!-- CR-05 文件元数据隐式标识(N2'-①):WeasyPrint 会把 author/description/generator
映射进 PDF 文档属性(Author/Subject/Creator);27 位内容编号随合规出口 publish_asset() 落地,此处不伪造 -->
<meta name="author" content="美搭(AI 生成内容)">
<meta name="description" content="本报告由人工智能生成,仅供参考。报告 {{ meta.report_id }} v{{ meta.version }},生成于 {{ date_text }}">
<meta name="generator" content="美搭 AI 报告生成服务">
<style> <style>
/* ── 页面与页脚:A4 分页,页脚带 AI 标识 + 免责语(合规 §合规与安全方案)── */ /* ── 页面与页脚:A4 分页,页脚带 AI 标识 + 免责语(合规 §合规与安全方案)── */
@page { @page {
+2 -2
View File
@@ -80,7 +80,7 @@ ERROR_META: dict[ErrorCode, ErrorMeta] = {
ErrorCode.BAD_REQUEST: ErrorMeta(400, "请求参数有误", "请检查后重试"), ErrorCode.BAD_REQUEST: ErrorMeta(400, "请求参数有误", "请检查后重试"),
ErrorCode.VALIDATION_FAILED: ErrorMeta(422, "请求参数校验未通过", "请按提示修改后重试"), ErrorCode.VALIDATION_FAILED: ErrorMeta(422, "请求参数校验未通过", "请按提示修改后重试"),
ErrorCode.IDEMPOTENCY_CONFLICT: ErrorMeta( ErrorCode.IDEMPOTENCY_CONFLICT: ErrorMeta(
409, "相同请求正在处理中", "请稍后查看任务中心,不要重复提交" 409, "相同请求正在处理中", "请稍后在首页「报告」中查看"
), ),
ErrorCode.UPLOAD_REJECTED: ErrorMeta( ErrorCode.UPLOAD_REJECTED: ErrorMeta(
400, "不支持的图片类型或超出大小限制", "请上传 20MB 以内的 JPG/PNG/WebP 图片" 400, "不支持的图片类型或超出大小限制", "请上传 20MB 以内的 JPG/PNG/WebP 图片"
@@ -115,7 +115,7 @@ ERROR_META: dict[ErrorCode, ErrorMeta] = {
ErrorCode.SMS_DAILY_LIMIT: ErrorMeta(429, "今日发送次数已达上限", "明天再试"), ErrorCode.SMS_DAILY_LIMIT: ErrorMeta(429, "今日发送次数已达上限", "明天再试"),
ErrorCode.INTERNAL_ERROR: ErrorMeta(500, "服务开小差了", "请稍后重试"), ErrorCode.INTERNAL_ERROR: ErrorMeta(500, "服务开小差了", "请稍后重试"),
ErrorCode.DB_ERROR: ErrorMeta(500, "服务开小差了", "请稍后重试"), ErrorCode.DB_ERROR: ErrorMeta(500, "服务开小差了", "请稍后重试"),
ErrorCode.TASK_STATE_INVALID: ErrorMeta(500, "任务状态异常", "请到任务中心重试"), ErrorCode.TASK_STATE_INVALID: ErrorMeta(500, "任务状态异常", "请稍后在首页「报告」中查看"),
ErrorCode.CONFIG_ERROR: ErrorMeta(500, "服务配置异常", "请联系客服"), ErrorCode.CONFIG_ERROR: ErrorMeta(500, "服务配置异常", "请联系客服"),
ErrorCode.NOT_IMPLEMENTED: ErrorMeta(501, "该能力尚未开放"), ErrorCode.NOT_IMPLEMENTED: ErrorMeta(501, "该能力尚未开放"),
ErrorCode.UPSTREAM_ERROR: ErrorMeta(502, "外部服务暂时不可用", "请稍后重试"), ErrorCode.UPSTREAM_ERROR: ErrorMeta(502, "外部服务暂时不可用", "请稍后重试"),
+58
View File
@@ -9,6 +9,8 @@
from __future__ import annotations from __future__ import annotations
import re
import zlib
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import UTC, datetime from datetime import UTC, datetime
@@ -24,6 +26,7 @@ from apps.api.main import create_app
from apps.api.routers.reports import report_export_service_dep from apps.api.routers.reports import report_export_service_dep
from packages.report.dimensions import DIMENSIONS from packages.report.dimensions import DIMENSIONS
from packages.report.export_service import ExportFile, ExportSubmission from packages.report.export_service import ExportFile, ExportSubmission
from packages.report.pdf import renderer as pdf_renderer
from packages.report.pdf.renderer import ( from packages.report.pdf.renderer import (
ReportPdfMeta, ReportPdfMeta,
_reject_all_urls, _reject_all_urls,
@@ -115,6 +118,61 @@ def test_url_fetcher_rejects_everything_including_file_scheme() -> None:
_reject_all_urls(url) _reject_all_urls(url)
# --- PDF 元数据 AI 声明(CR-05 隐式标识,R4 N2'-①) -----------------------
def _render_template_html() -> str:
"""用渲染器同款 Jinja 环境+上下文形状渲染 HTML(与 render_report_pdf 内部一致)。"""
template = pdf_renderer._env.get_template("report.html.j2")
return template.render(
meta=META,
date_text=META.generated_at.strftime("%Y年%m月%d日"),
summary="",
sections=[],
dimensions=[],
core_dimensions=[],
ok_count=0,
)
def _decompressed_streams(pdf: bytes) -> list[bytes]:
"""解出 PDF 里全部 FlateDecode 流——WeasyPrint 把 Info 字典放进压缩对象流,
裸字节 grep 不到 /Author 等标记(zlib 为标准库,不新增依赖)。"""
out: list[bytes] = []
for m in re.finditer(rb"stream\r?\n", pdf):
start = m.end()
end = pdf.find(b"endstream", start)
if end < 0:
continue
try:
out.append(zlib.decompress(pdf[start:end]))
except zlib.error:
continue
return out
def test_report_html_declares_ai_generation_meta() -> None:
html = _render_template_html()
assert '<meta name="author" content="美搭(AI 生成内容)">' in html
assert 'name="description"' in html
# description 携带报告号/版本/日期(ReportPdfMeta 现成数据,不新增字段)
assert "报告 101 v1" in html
assert "生成于 2026年08月29日" in html
def test_rendered_pdf_carries_ai_metadata() -> None:
# WeasyPrint 把 meta author/description/generator 映射进 PDF 信息字典
# (Author/Subject/Creator);值编码为带 BOM 的 UTF-16BE 十六进制文本串。
pdf = render_report_pdf(_sample_result(), meta=META)
joined = b"\n".join(_decompressed_streams(pdf))
for marker in (b"/Author", b"/Subject", b"/Creator"):
assert marker in joined, f"PDF 信息字典缺 {marker!r}"
author_hex = "feff" + "美搭(AI 生成内容)".encode("utf-16-be").hex()
assert author_hex.encode("ascii") in joined
desc_hex = "feff" + "本报告由人工智能生成,仅供参考。报告 101 v1".encode("utf-16-be").hex()
assert desc_hex.encode("ascii") in joined
# --- 路由接线(假服务,不连库) -------------------------------------------- # --- 路由接线(假服务,不连库) --------------------------------------------