Markdown 导入导出管道
本节讲 youteacher_content 里两条相关的内容管道:一条面向 agent 的 markdown 导入器,把 markdown 加媒体变成一篇和编辑器手工创建完全一样的草稿文章;另一条是基于 zip 的文章与分类批量导出/导入。两条都围着同一个事实转:文章以 BlockNote JSON 存储,媒体放在对象存储里,所以每条路径都得转换成这个形状,并保持媒体引用的一致。
面向 agent 的 markdown 导入
只有一个端点 POST /import-markdown,仅对管理员开放。它接收 multipart/form-data:文本字段(markdown、可选 title、可选 categorySlug),加上可重复的文件分块——一个可选的 featuredImage,以及任意多个 media 文件。成功时返回 201 和一份文章 DTO,与在编辑器里创建出来的文章无法区分——同样的 BlockNote 内容、同样的草稿状态。未知的文件字段会被记日志后忽略。
大小在两层被限制。markdown 正文上限 1 MB。每个媒体文件按 10 MB 的应用级上限重新校验;当某个分块触到底层 multipart 限制时,错误被重映射成唯一的规范化 400 / file_too_large,这样无论客户端从哪条路撞到上限,看到的都是同一个错误码。
端点背后的 use case 按"先校验"的有序步骤执行:
- 文本检查。 markdown 不能为空。标题取自显式字段,取不到就取 markdown 里的第一个 H1;两者都没有则拒绝导入。
- 分类。 若给了
categorySlug,它必须能解析到一个已存在的分类。 - 媒体校验,在任何上传之前。 每个文件的 MIME 必须是
image/开头,且不超过大小上限。 - 引用校验。 markdown 可以用
media:文件名记号引用媒体;每个这样的引用都必须对应一个已上传的文件,否则以unknown_media_ref失败。 - 以 saga 方式上传。 到这一步才把文件上传到对象存储,并记下每个存储 key。文件按文件名去重,重复引用只上传一次。
- 重写并转换。 把
media:文件名记号重写成存储 URL,再把 markdown 转成 BlockNote JSON。若没提供 featured image,就把内容里的第一张内联图片作为 featured image。 - 以草稿持久化。 创建文章,
contentFormat为blocknote;slug 冲突时给标题加一段短 id 后缀,与普通创建路径一致。
saga 就是那条安全性质:上传之后任何一步抛错,都会先把已上传的每个对象删掉再让错误浮上来,所以一次失败的导入不会留下孤立的媒体。
Markdown → BlockNote 转换
转换用 marked 词法分析器(GitHub 风格),把它的 token 树映射到 BlockNote 的规范块类型:标题(层级最高到 3)、段落、无序与有序列表项、代码块、图片。段落会在每个图片 token 处被切开——文本段变成段落块,图片变成独立的图片块,对应 BlockNote 编辑器里"这是一张照片"后面跟图片这一行的排布方式。内联样式(粗体、斜体、行内代码、链接)会被保留;文本段落里的内联图片会降级为它的 alt 文本,因为 BlockNote 没有内联图片类型。无法识别的东西一律落为段落,空输入产出单个空段落。
在标准 markdown 之上,转换器还认识一套通用的 slash-command 指令语法,用于编辑器的自定义块。叶子指令是单独一行的 ::name{key="value"};容器指令以 :::name{...} 开始,跨越随后的若干行,以 ::: 收尾。指令名原样成为 BlockNote 块的 type,容器体则原封不动放进 props.content。因为名字是直通映射的,编辑器注册的任何自定义块都无需新增转换代码即可导入。
基于 zip 的导出与导入
批量搬运走一个自包含的 zip。导出按显式 id、按分类、或全部(上限 500 篇)选取文章,然后组装成一个包:一个 manifest.json(版本、时间戳、计数)、一个 categories/categories.json(含每个祖先分类,按深度排序)、以及每篇文章一个 posts/<slug>.json。媒体是有意思的部分——导出器收集每个内部媒体 URL(featured 图、遍历 BlockNote 内容找到的内联图、以及附件),把每一个下载进 zip 的 media/ 前缀路径下,并把每篇文章内容和 featured-image 字段里的 URL 重写成这些相对路径。只有落在实例对象存储前缀下的 URL 才被当作内部 URL,外部 URL 原样保留。下载失败的媒体会被跳过并记警告,而不是中止整个导出。
导入是它的逆过程,而且刻意做得宽容。它校验 manifest 版本,父先子后地导入分类(同 slug 已存在就复用,否则创建),把每个媒体文件上传回存储并建立 路径→URL 映射,再逐篇导入文章。内容通过把相对媒体路径替换回新的存储 URL 来还原。每篇文章都以草稿导入;slug 冲突加 id 后缀,唯一约束冲突触发同样的后缀兜底,别名只在尚未被占用时才恢复。单篇文章、单个分类或单个媒体上的错误会被收进一份结果摘要,而不是让整次导入失败,所以一个残缺的包也能把能落的部分落下来。
BlockNote 媒体遍历器被两个方向共用:它解析内容 JSON,递归穿过块的 content 与 children,读取图片的 props.url,要么收集内部 URL(导出),要么在深拷贝的树上按映射替换(导入)。zip 本身用 archiver 以压缩级别 6 构建,在内存里缓冲分块,只 finalize 一次。
为什么是这个形状
反复出现的主题是单一的内容表示 + 一致的媒体引用。markdown 导入的存在,是为了让 agent 不必懂 BlockNote 的 JSON 就能产出编辑器级的文章;指令语法让这扇门对自定义块保持敞开,无需逐块写代码。saga 与"上传前先校验"的次序,是为了失败时绝不留下上传了一半的媒体。导出和导入在实例之间搬运同一份表示,来去两程都重写媒体引用,让一个包可移植、可重新托管,而不是绑死在某一个存储源上。