From 124024a5470f5e4dc51d5b65c7367763d32e613c Mon Sep 17 00:00:00 2001 From: yxx <2412119399@qq.com> Date: Sat, 5 Sep 2026 23:31:53 +0800 Subject: [PATCH] =?UTF-8?q?fix(export):=20=E4=B8=BA=E5=87=BD=E6=95=B0?= =?UTF-8?q?=E5=9B=BE=E5=83=8F=E4=B8=8E=E5=AF=BC=E5=87=BA=E4=BA=A7=E7=89=A9?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E8=B5=84=E6=BA=90=E4=B8=8A=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 针对 PR 审阅「函数数量没有限制,可能生成数百 MB 的 SVG」: - parser: 单块 function-plot 表达式上限 _MAX_EXPRESSIONS=16,超限整块回退 - html: 单篇文档函数图像上限 _MAX_FUNCTION_PLOTS=16,超出回退源码占位 - service: 输入源 MAX_MARKDOWN_CHARS、产物 MAX_EXPORT_BYTES,超限分别 拒绝创建或标记 failed(EXPORT_OUTPUT_TOO_LARGE) - 补充 4 条回归测试与文档说明 Co-Authored-By: Claude Code --- backend/app/export/exporters/html.py | 11 +++++++ backend/app/export/service.py | 26 +++++++++++++++- backend/app/plot/parser.py | 12 ++++++++ backend/tests/test_export.py | 37 +++++++++++++++++++++++ backend/tests/test_plot.py | 22 ++++++++++++++ docs/contracts/第二阶段接口契约-开发版.md | 1 + docs/development/Export开发说明.md | 16 ++++++++-- 7 files changed, 121 insertions(+), 4 deletions(-) diff --git a/backend/app/export/exporters/html.py b/backend/app/export/exporters/html.py index 26d53ae..a57a490 100644 --- a/backend/app/export/exporters/html.py +++ b/backend/app/export/exporters/html.py @@ -22,6 +22,9 @@ _RAW_HTML_WARNING = "原始 HTML 已按纯文本转义保留" # 链接/图片地址允许的协议;无 scheme 的相对地址视为安全,其余协议一律降级 _ALLOWED_URL_SCHEMES = frozenset({"http", "https", "mailto"}) +# 单篇文档允许的函数图像数量上限,超出部分回退占位,防止多图块并发采样耗尽内存/线程 +_MAX_FUNCTION_PLOTS = 16 + def _safe_url(url: str) -> str | None: """校验 URL 协议;安全返回原串,不安全返回 None。""" @@ -69,6 +72,7 @@ class HtmlExporter: def render(self, document: Document, options: ExportOptions) -> ExportResult: """同步渲染;CPU 密集,调用方应放入线程执行,避免阻塞事件循环。""" self._options = options + self._plot_count = 0 warnings: list[str] = [] body = self._render_children(document.children, warnings) content = self._assemble(document, options, body, warnings) @@ -203,6 +207,13 @@ class HtmlExporter: return f"函数图像:{diag.message}{loc}" def _render_function_plot(self, node: DocumentNode, warnings: list[str]) -> str: + # 文档级数量上限:超出部分直接回退占位,不解析不采样,防止海量图像耗尽资源 + self._plot_count += 1 + if self._plot_count > _MAX_FUNCTION_PLOTS: + warnings.append( + f"函数图像:文档内函数图像数量超过上限 {_MAX_FUNCTION_PLOTS},已回退为源码占位" + ) + return f'
{html.escape(node.text)}
' # 解析与渲染共同纳入局部异常回退:单个图像失败只回退占位 + warning, # 绝不阻断整篇导出(含复杂表达式触发的 RecursionError 等异常)。 try: diff --git a/backend/app/export/service.py b/backend/app/export/service.py index 6730246..13a8117 100644 --- a/backend/app/export/service.py +++ b/backend/app/export/service.py @@ -39,8 +39,10 @@ _jobs: dict[str, ExportJob] = {} _tasks: dict[str, asyncio.Task] = {} _cancel_flags: dict[str, asyncio.Event] = {} MAX_JOBS = 100 -# markdown 源大小上限,防止未保存预览塞爆内存/产物 +# 输入源(note / markdown)统一大小上限,防止未保存预览或超长笔记塞爆内存/产物 MAX_MARKDOWN_CHARS = 200_000 +# 最终导出产物大小上限,防止超大 HTML 耗尽内存/磁盘 +MAX_EXPORT_BYTES = 20 * 1024 * 1024 # 20 MB # 产物有效期 FILE_TTL = timedelta(hours=24) @@ -51,6 +53,10 @@ class ExportCancelled(Exception): """导出在渲染前被取消时抛出,用于标记 cancelled。""" +class ExportTooLarge(Exception): + """导出产物超过大小上限时抛出,用于标记 failed 并携带专用错误码。""" + + def _now() -> datetime: return datetime.now(timezone.utc) @@ -123,6 +129,13 @@ async def _resolve_source(source: ExportSource) -> tuple[str, str, dict | None]: "note not found", {"note_id": source.note_id}, ) + if len(note.markdown) > MAX_MARKDOWN_CHARS: + raise ApiError( + 400, + "EXPORT_OPTIONS_INVALID", + f"note source exceeds {MAX_MARKDOWN_CHARS} characters", + {"size": len(note.markdown), "limit": MAX_MARKDOWN_CHARS}, + ) metadata = { "file_path": note.file_path, "tags": note.tags, @@ -210,6 +223,8 @@ async def _execute( result = await asyncio.to_thread(_render_document, document, options) if cancel_event.is_set(): raise ExportCancelled() + if len(result.content) > MAX_EXPORT_BYTES: + raise ExportTooLarge() out_dir = get_settings().exports_path out_dir.mkdir(parents=True, exist_ok=True) @@ -241,6 +256,15 @@ async def _execute( "completed_at": _now(), } ) + except ExportTooLarge: + _jobs[job_id] = _jobs[job_id].model_copy( + update={ + "status": ExportStatus.failed, + "error": "Export output exceeds size limit.", + "error_code": "EXPORT_OUTPUT_TOO_LARGE", + "completed_at": _now(), + } + ) except Exception as exc: # 渲染失败不拖垮服务,只记日志与项目错误码 logger.exception("Export failed: job_id=%s", job_id) _jobs[job_id] = _jobs[job_id].model_copy( diff --git a/backend/app/plot/parser.py b/backend/app/plot/parser.py index 30f9be9..46a9984 100644 --- a/backend/app/plot/parser.py +++ b/backend/app/plot/parser.py @@ -51,6 +51,8 @@ _NUMBER_RE = re.compile(r"^(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$") # 用白名单校验提前拦截,保证失败走正常诊断路径而不是异常逃逸出导出链路。 _MAX_AST_DEPTH = 200 _MAX_AST_NODES = 1000 +# 单块 function-plot 允许的表达式数量上限,防止海量表达式导致超大 SVG 与海量采样求值 +_MAX_EXPRESSIONS = 16 class PlotParseError(Exception): @@ -368,6 +370,16 @@ def parse_source(source: str) -> FunctionPlotParseResult: has_error = True continue expressions.append(FunctionPlotExpression(expression=expr_text)) + # 表达式数量超限:整块回退并提前终止,避免对海量表达式做采样求值 + if len(expressions) > _MAX_EXPRESSIONS: + diagnostics.append( + PlotDiagnostic( + severity="error", + code="FUNCTION_PLOT_TOO_MANY_EXPRESSIONS", + message=f"表达式数量超过上限 {_MAX_EXPRESSIONS},已回退为源码占位", + ) + ) + return FunctionPlotParseResult(plot=None, diagnostics=diagnostics) if has_error: return FunctionPlotParseResult(plot=None, diagnostics=diagnostics) diff --git a/backend/tests/test_export.py b/backend/tests/test_export.py index 38fda15..a00383d 100644 --- a/backend/tests/test_export.py +++ b/backend/tests/test_export.py @@ -412,3 +412,40 @@ def test_export_source_requires_matching_field() -> None: ExportSource(type=ExportSourceType.note, note_id=None) with pytest.raises(ValidationError): ExportSource(type=ExportSourceType.markdown, markdown=None) + + +# --------------------------------------------------------------------------- # +# 审阅回归:资源上限 +# --------------------------------------------------------------------------- # +def test_export_note_source_size_limit(monkeypatch) -> None: + # P1:note 源超出 MAX_MARKDOWN_CHARS 应在创建期拒绝,不进入后台渲染 + from app.services import note_service + + monkeypatch.setattr(export_service, "MAX_MARKDOWN_CHARS", 10) + + async def _go(): + note = await note_service.create_note( + title="超长笔记", markdown="a" * 20, folder="导出", tags=[] + ) + return await export_service.create_export( + ExportRequest( + source=ExportSource(type=ExportSourceType.note, note_id=note.note_id), + format=ExportFormat.html, + ) + ) + + with pytest.raises(ApiError) as exc: + asyncio.run(_go()) + assert exc.value.status_code == 400 + assert exc.value.code == "EXPORT_OPTIONS_INVALID" + + +def test_export_output_too_large(monkeypatch) -> None: + # P1:产物超出 MAX_EXPORT_BYTES 应标记 failed 且不落盘 + monkeypatch.setattr(export_service, "MAX_EXPORT_BYTES", 10) + + finished = _create_and_wait(_markdown_request("# 产物超限")) + assert finished.status == ExportStatus.failed + assert finished.error_code == "EXPORT_OUTPUT_TOO_LARGE" + assert finished.file is None + assert not (get_settings().exports_path / f"{finished.job_id}.html").exists() diff --git a/backend/tests/test_plot.py b/backend/tests/test_plot.py index 8c7e48b..cd95a46 100644 --- a/backend/tests/test_plot.py +++ b/backend/tests/test_plot.py @@ -247,3 +247,25 @@ def test_render_svg_extreme_range_no_nan() -> None: assert "nan" not in rendered.content assert "inf" not in rendered.content assert any("range" in w for w in rendered.warnings) + + +# --------------------------------------------------------------------------- # +# 审阅回归:函数/图像数量上限 +# --------------------------------------------------------------------------- # +def test_parse_source_rejects_too_many_expressions() -> None: + # P1:单块表达式数量超限应整块回退,避免海量采样求值 + source = "\n".join(f"y = x + {i}" for i in range(50)) + result = parse_source(source) + assert result.plot is None + assert any(d.code == "FUNCTION_PLOT_TOO_MANY_EXPRESSIONS" for d in result.diagnostics) + + +def test_html_exporter_limits_function_plot_count() -> None: + # P1:文档内函数图像数量超限,超出部分回退占位,不耗尽资源 + blocks = "\n\n".join("```function-plot\ny = x\n```" for _ in range(20)) + result = asyncio.run(HtmlExporter().export(parse_document(blocks), ExportOptions())) + html = result.content.decode("utf-8") + # 上限 16:前 16 个渲染为 SVG,其余 4 个回退占位 + assert html.count('
') == 16 + assert html.count('
') == 4
+    assert any("函数图像数量超过上限" in w for w in result.warnings)
diff --git a/docs/contracts/第二阶段接口契约-开发版.md b/docs/contracts/第二阶段接口契约-开发版.md
index 0ed0f05..8dc80e4 100644
--- a/docs/contracts/第二阶段接口契约-开发版.md
+++ b/docs/contracts/第二阶段接口契约-开发版.md
@@ -1228,6 +1228,7 @@ EXPORT_RENDER_FAILED
 EXPORT_UNSUPPORTED_CONTENT
 EXPORT_JOB_NOT_FOUND
 EXPORT_FILE_EXPIRED
+EXPORT_OUTPUT_TOO_LARGE
 ```
 
 ---
diff --git a/docs/development/Export开发说明.md b/docs/development/Export开发说明.md
index dac42f7..40a1a92 100644
--- a/docs/development/Export开发说明.md
+++ b/docs/development/Export开发说明.md
@@ -1,6 +1,6 @@
 # Export 开发说明
 
-> 所属模块:Export Service(后端,负责人 yxx)。本次交付「多格式文档导出」第一步:Markdown → HTML 的完整生命周期;PDF/DOCX 与函数图像静态渲染在后续 PR 补齐。契约对应 [第二阶段接口契约 §10](../contracts/第二阶段接口契约-开发版.md)。
+> 所属模块:Export Service(后端,负责人 yxx)。交付「多格式文档导出」:Markdown → HTML 的完整生命周期与 function-plot 静态 SVG 渲染;PDF/DOCX 在后续 PR 补齐。契约对应 [第二阶段接口契约 §10](../contracts/第二阶段接口契约-开发版.md)。
 
 ## 定位
 
@@ -41,7 +41,7 @@ fenced code 按语言分流:`mermaid` → `mermaid` 节点、`function_plot`/`
 
 ## HtmlExporter
 
-递归渲染 Document AST 为完整 HTML5 文档(`` + `` 内嵌基础 CSS + ``),标题/正文/元信息文本一律 `html.escape`。`mermaid` 与 `function_plot` 无法静态表达,渲染为占位 `
`/`
` 并记 warning,不静默丢失;`code_theme` 仅作为代码容器 class,不引入 JS 高亮库。无法表示的节点统一 `warnings.append(...)` 跳过。
+递归渲染 Document AST 为完整 HTML5 文档(`` + `` 内嵌基础 CSS + ``),标题/正文/元信息文本一律 `html.escape`。`function_plot` 解析为静态 SVG 内嵌(解析/渲染失败或超限时回退 `
` 占位并记 warning),`mermaid` 无法静态表达,渲染为占位 `
` 并记 warning,均不静默丢失;`code_theme` 仅作为代码容器 class,不引入 JS 高亮库。无法表示的节点统一 `warnings.append(...)` 跳过。
 
 ## 运行生命周期
 
@@ -56,6 +56,15 @@ fenced code 按语言分流:`mermaid` → `mermaid` 节点、`function_plot`/`
 
 产物写入 `settings.exports_path`(默认 `backend/data/exports/`,可通过 `APP_EXPORTS_PATH` 覆盖,已加入 `.gitignore`),文件名为 `{job_id}.html`,下载 `Content-Disposition` 用 `_safe_download_name` 清洗标题得到。`ExportFile` 记录 `sha256`、`size` 与 `expires_at`(`completed_at + 24h`),过期返回 `EXPORT_FILE_EXPIRED`(410)。
 
+## 资源上限
+
+为防止超大输入或海量函数图像耗尽内存/线程,导出链路内置以下上限:
+
+- 输入源(`note` 与 `markdown`)统一限制 `MAX_MARKDOWN_CHARS = 200_000` 字符,超限返回 `EXPORT_OPTIONS_INVALID`。
+- 单个 `function-plot` 图块最多 16 条表达式,超限整块回退占位并记结构化诊断 `FUNCTION_PLOT_TOO_MANY_EXPRESSIONS`。
+- 单篇文档最多 16 个函数图像,超出部分回退占位并记 warning。
+- 最终产物大小上限 `MAX_EXPORT_BYTES = 20 MB`,超限任务标记 failed 并返回 `EXPORT_OUTPUT_TOO_LARGE`。
+
 ## 错误码
 
 ```text
@@ -67,6 +76,7 @@ EXPORT_UNSUPPORTED_CONTENT   422
 EXPORT_JOB_NOT_FOUND         404
 EXPORT_FILE_EXPIRED          410
 EXPORT_CAPACITY_EXCEEDED     429
+EXPORT_OUTPUT_TOO_LARGE      413
 ```
 
 ## 测试
@@ -81,5 +91,5 @@ uv run pytest -q
 ## 范围外(后续 PR)
 
 - PDF / DOCX 导出(`python-docx` 等底层库在 PoC 后冻结,封装在 Exporter Adapter 内)。
-- 函数图像绘制(FunctionPlot 结构化模型 + 白名单表达式解析器 + SVG 静态渲染,契约 §10.4/§12)。
+- 函数图像交互预览与缩放(前端 JS Renderer 负责,后端仅提供静态 SVG)。
 - 代码语法高亮(当前仅 CSS class 占位)。