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('') == 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 占位)。