fix(export): 为函数图像与导出产物增加资源上限

针对 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 <noreply@anthropic.com>
This commit is contained in:
yxx
2026-09-05 23:31:53 +08:00
co-authored by Claude Code
parent b87f94551b
commit 124024a547
7 changed files with 121 additions and 4 deletions
+11
View File
@@ -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'<pre class="function-plot">{html.escape(node.text)}</pre>'
# 解析与渲染共同纳入局部异常回退:单个图像失败只回退占位 + warning,
# 绝不阻断整篇导出(含复杂表达式触发的 RecursionError 等异常)。
try:
+25 -1
View File
@@ -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(
+12
View File
@@ -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)
+37
View File
@@ -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:
# P1note 源超出 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()
+22
View File
@@ -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('<figure class="function-plot">') == 16
assert html.count('<pre class="function-plot">') == 4
assert any("函数图像数量超过上限" in w for w in result.warnings)
@@ -1228,6 +1228,7 @@ EXPORT_RENDER_FAILED
EXPORT_UNSUPPORTED_CONTENT
EXPORT_JOB_NOT_FOUND
EXPORT_FILE_EXPIRED
EXPORT_OUTPUT_TOO_LARGE
```
---
+13 -3
View File
@@ -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 文档(`<!doctype html>` + `<head>` 内嵌基础 CSS + `<body>`),标题/正文/元信息文本一律 `html.escape``mermaid``function_plot` 无法静态表达,渲染为占位 `<pre class="mermaid">`/`<pre class="function-plot">` 并记 warning,不静默丢失;`code_theme` 仅作为代码容器 class,不引入 JS 高亮库。无法表示的节点统一 `warnings.append(...)` 跳过。
递归渲染 Document AST 为完整 HTML5 文档(`<!doctype html>` + `<head>` 内嵌基础 CSS + `<body>`),标题/正文/元信息文本一律 `html.escape``function_plot` 解析为静态 SVG 内嵌(解析/渲染失败或超限时回退 `<pre class="function-plot">` 占位并记 warning),`mermaid` 无法静态表达,渲染为占位 `<pre class="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 占位)。