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
+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(