22 Markdown ZIP 转换工程设计

1. 先设计状态机

created
  → uploading
  → validating
  → extracting
  → rendering
  → archiving
  → ready
  → downloading
  → expired/deleted

任意处理中状态 → failed

状态应保存在数据库或持久任务系统,而不是只存在 Express 请求局部变量中。短接口也至少要在日志中拥有 taskId。

2. 建议目录

TEMP_DIR/jobs/<taskId>/
├─input/source.zip
├─extract/
├─rendered/
└─build/result.zip.tmp

STORAGE_DIR/exports/<taskId>/result.zip

TEMP_DIR 可随 TTL 清理,STORAGE_DIR 保存用户可下载结果。二者不要放在项目源码目录,也不要让 Nginx 直接公开整个根目录。

3. 上传阶段

Multer 负责 multipart 解析和压缩包大小初步限制。Controller 只提取经过白名单的数据:

interface CreateMarkdownExportInput {
  uploadedPath: string;
  originalName: string;
}

不要相信:

originalname
mimetype
扩展名
客户端提供的相对路径

这些只用于初筛或展示。真实格式要通过 ZIP Reader 打开验证。

4. ZIP Entry 验证

处理每个 Entry 前至少检查:

原始路径没有 NUL
使用 ZIP POSIX 路径语义
不是绝对路径
没有 . 或 .. 路径段
不是符号链接
路径总长度和段长度受限
文件数量受限
Header 声明大小受限
累计实际解压字节受限
目标路径位于 extractRoot
目标没有与已有 Entry 冲突

不要这样处理:

const safeName = normalizeFileName(entry.fileName);

entry.fileName 包含目录。如果函数只保留文件名,会把:

guide/a/index.md
guide/b/index.md

都写到根目录,造成覆盖。应先验证完整 POSIX 路径,再逐段构造本地路径。展示用文件名规范化与归档路径验证是两个不同职责。

5. yauzl 的事件和 Entry Stream

概念流程:

打开 ZipFile
  → entry
  → 验证元数据
  → openReadStream(entry)
  → pipeline(entryStream, fileWriteStream)
  → 请求下一个 entry
  → end

使用 lazy entries 时,只有当前 Entry 完整处理后才调用 readEntry(),自然限制同时打开的 Entry 数量。

需要管理三层错误:

ZipFile error
Entry ReadStream error
目标 WriteStream error

Entry 复制应使用:

await pipeline(entryReadStream, targetWriteStream);

发生错误时关闭 ZipFile、销毁当前 Stream,并让上层统一删除任务目录。不要只监听 writeStream.finish

6. 实际解压字节计数

不能只相信 Entry Header。可以在中间放计数 Transform:

const limiter = new Transform({
  transform(chunk: Buffer, encoding, callback) {
    entryBytes += chunk.length;
    totalBytes += chunk.length;

    if (
      entryBytes > maxEntryBytes ||
      totalBytes > maxTotalBytes
    ) {
      callback(new Error("解压大小超过限制"));
      return;
    }

    callback(null, chunk);
  },
});

然后:

await pipeline(entryStream, limiter, outputStream);

7. 渲染阶段

遍历 extract/ 下经过验证的 .md 文件,计算相对路径:

extract/guide/start.md
  → relative = guide/start.md
  → rendered/guide/start.html

每个 Markdown 有独立大小上限。渲染步骤:

readFile(受限 Markdown)
  → markdown-it.render()
  → sanitize-html
  → 写临时 HTML
  → rename 到目标

语法高亮必须由 markdown-it 的 highlight 配置调用 highlight.js,才能生成带 hljs-* class 的 span。只给 <code> 设置整体颜色不会产生 Token 级着色。

图片等静态资源适合 Pipeline 复制。链接重写要以 Markdown 文件所在目录为基准,不能全部相对于 ZIP 根目录。

8. Archiver 阶段

推荐把要打包的目录作为目录树加入:

archive.directory(renderedDirectory, false);

false 的含义是“不额外包一层顶级目录”,不是压平子目录。若最终层级消失,应检查更早的 rendered 目录、archive.file()name、Entry 名称生成和是否只传了 basename。

Archiver 与文件 WriteStream 是两个不同完成面:

archive.finalize()
  表示不再添加 Entry,开始完成归档

输出 WriteStream finish/close
  表示 ZIP 字节写入目标完成/资源关闭

应在调用 finalize() 前设置错误监听,并使用 Promise/finished(output) 等方式等待输出完成。Archiver 自身 warning 要区分可恢复警告和致命错误。

9. 下载阶段

只有结果 ZIP 已完整生成并移动到 finalPath 后才能设置下载响应。推荐:

res.attachment(downloadName);
await pipeline(createReadStream(finalPath), res);

客户端断开时 Pipeline reject。若结果设计为可重复下载,不要因为一次中断立即删除 finalPath;若是一次性临时响应,则按业务策略清理。

10. 一个上层骨架

export async function createMarkdownExport(
  input: CreateMarkdownExportInput,
  signal?: AbortSignal,
): Promise<MarkdownExportResult> {
  const context = await createTaskContext(input);

  try {
    await validateUpload(context);
    await extractZip(context, signal);
    await renderMarkdownFiles(context, signal);
    await archiveRenderedDirectory(context, signal);
    return await publishResult(context);
  } catch (error: unknown) {
    await markTaskFailed(context, error);
    throw error;
  } finally {
    await cleanupIntermediateFiles(context);
  }
}

它只展示阶段和职责,不是核心实现答案。每层都接受 task context 和取消信号,避免依赖全局变量。

11. 错误响应边界

转换完成前尚未发送响应头
  → 可以返回 JSON 业务错误

开始 ZIP 下载后
  → 不能再切换成 JSON
  → 中断 Stream、记日志、清理/保留结果

把“生成结果”和“发送结果”分成两步,比边生成边下载更容易为初学者建立正确错误边界。

练习题

  1. 为每个阶段列出输入、输出、句柄和失败清理动作。
  2. 设计拒绝大小写折叠冲突 Entry 的数据结构。
  3. 用实际字节计数防止 Header 大小不可信。
  4. 验证 archive.directory(dir, false) 是否保留子目录。
  5. 客户端在 50% 下载时断开,定义任务和结果文件状态。