这一章把前面的能力接入 HTTP 接口。目标是让每一层只处理自己擅长的事情,避免一个 Route 同时负责上传、解压、安全检查、Markdown 转换、归档和清理。
认证和权限
→ 上传限流
→ Multer 接收 multipart/form-data
→ Controller 检查 req.file
→ Import Service 使用 yauzl 校验并提取
→ Markdown Service 转换和净化 HTML
→ Archive Service 使用 archiver 创建 ZIP
→ Express 返回下载
→ finally 清理工作目录
各层的职责不同:
Multer 的 fileSize 不能防止 ZIP 炸弹;文件扩展名和 mimetype 也不能证明上传内容确实是 ZIP。
新人项目不需要复杂框架,可以先使用:
services/markdown-export/
├── markdown-export.router.ts
├── markdown-export.controller.ts
├── zip-import.service.ts
├── markdown-render.service.ts
├── zip-export.service.ts
└── markdown-export.types.ts
import { Router } from "express";
import { uploadMarkdownZip } from "./upload-middleware";
import { exportMarkdownZip } from "./markdown-export.controller";
export const markdownExportRouter = Router();
markdownExportRouter.post(
"/markdown/export",
requireLogin,
exportRateLimit,
uploadMarkdownZip.single("archive"),
exportMarkdownZip,
);
认证和限流放在 Multer 前面,可以在服务器接收大文件前拒绝无权限或过于频繁的请求。
Controller 负责:
req.file;req、resService 接收明确参数:
interface ImportOptions {
zipPath: string;
extractionDirectory: string;
signal: AbortSignal;
}
不要把整个 req、res 传进 yauzl Service。这样 Service 更容易单元测试,也不会在深层代码中意外发送第二次响应。
export interface ZipImportLimits {
maxEntries: number;
maxSingleEntryBytes: number;
maxTotalUncompressedBytes: number;
maxCompressionRatio: number;
maxPathDepth: number;
}
export interface ImportedFile {
archivePath: string;
localPath: string;
size: number;
kind: "markdown" | "asset";
}
export interface MarkdownProjectManifest {
entryMarkdownPath: string;
files: ImportedFile[];
totalUncompressedBytes: number;
}
export interface MarkdownExportResult {
outputZipPath: string;
downloadName: string;
}
这些接口不是为了“看起来高级”,而是让不同 Service 对输入输出达成明确约定。
res.download()这是建议新人首先掌握的方案:
import type { NextFunction, Request, Response } from "express";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
export async function exportMarkdownZip(
req: Request,
res: Response,
next: NextFunction,
): Promise<void> {
const workDirectory = await mkdtemp(
path.join(tmpdir(), "markdown-export-"),
);
try {
if (req.file === undefined) {
res.status(400).json({
code: "ARCHIVE_REQUIRED",
message: "请选择 ZIP 文件",
});
return;
}
const result = await markdownExportService.convert({
uploadedZipPath: req.file.path,
workDirectory,
});
await new Promise<void>((resolve, reject) => {
res.download(
result.outputZipPath,
result.downloadName,
(error) => {
if (error !== undefined) {
reject(error);
return;
}
resolve();
},
);
});
} catch (error: unknown) {
next(error);
} finally {
await rm(workDirectory, {
recursive: true,
force: true,
}).catch((cleanupError: unknown) => {
logger.error(
{ cleanupError, workDirectory },
"清理 Markdown 导出目录失败",
);
});
}
}
这里必须使用 res.download() 的回调再认为传输结束。否则 Controller 可能在下载仍进行时进入 finally,提前删除 ZIP。
上传文件是否位于 workDirectory 取决于 Multer 配置。如果它在另一个临时目录,还要单独清理 req.file.path,并确保只删除服务器自己创建的明确路径。
res当文件较大、不希望生成中间 ZIP 时,可以直接流式响应:
import archiver from "archiver";
import type { Response } from "express";
import { finished } from "node:stream/promises";
async function sendDirectoryAsZip(
res: Response,
sourceDirectory: string,
downloadName: string,
): Promise<void> {
const archive = archiver("zip", {
zlib: { level: 6 },
});
archive.on("error", (error) => {
res.destroy(error);
});
archive.on("warning", (error) => {
if (error.code !== "ENOENT") {
res.destroy(error);
}
});
res.type("application/zip");
res.attachment(downloadName);
archive.pipe(res);
archive.directory(sourceDirectory, false);
const responseDone = finished(res);
await archive.finalize();
await responseDone;
}
直接响应时通常无法提前知道 Content-Length,Node.js 会按 HTTP 版本选择分块或其他传输方式。这不是错误。
headersSent 为什么重要在 ZIP 还没写入响应前,可以返回 JSON:
res.status(422).json({
code: "MARKDOWN_ENTRY_NOT_FOUND",
message: "ZIP 中没有找到入口 Markdown",
});
但 archiver 一旦开始输出,响应头通常已经发送:
console.log(res.headersSent); // 很可能为 true
此时不能这样做:
// 错误:可能触发 ERR_HTTP_HEADERS_SENT
res.status(500).json({ message: "归档失败" });
错误处理中应区分:
app.use((error: unknown, req: Request, res: Response, next: NextFunction) => {
if (res.headersSent) {
next(error);
return;
}
res.status(500).json({
code: "INTERNAL_SERVER_ERROR",
message: "服务器处理失败",
});
});
对于已经开始的二进制响应,通常应该销毁流/连接并记录错误。前端会把它视为下载失败或文件不完整。
Express 5 会把 async Route 返回的 rejected Promise 交给错误中间件:
router.post("/export", async (req, res) => {
throw new Error("会进入错误中间件");
});
但是 EventEmitter 的 error 不会自动进入这个 Promise:
archive.on("error", (error) => {
// Express 不会仅因为这里出现 error 事件就自动调用 next(error)
});
所以应当使用 finished()、pipeline() 或自己创建 Promise,把流的成功和失败纳入 await 链路。
function makeDownloadName(projectName: string): string {
const safeName = projectName
.normalize("NFC")
.replace(/[\u0000-\u001F\u007F]/g, "")
.replace(/[\\/:*?"<>|]/g, "-")
.trim()
.slice(0, 80);
return `${safeName || "markdown-export"}.zip`;
}
然后交给 Express:
res.attachment(makeDownloadName(projectName));
不要将这个“下载展示名”用于服务器临时路径。服务器路径应该使用 mkdtemp() 或 UUID 生成,与用户名称分开。
初版功能建议:
上传到磁盘
→ yauzl 流式提取到隔离目录
→ 转换到专用 output 目录
→ archiver 生成临时 ZIP
→ res.download()
→ 下载回调结束后 finally 清理
等你已经有监控、资源限制和完整的中止处理,再考虑 archive.pipe(res),而不是一开始为了少一次磁盘 I/O 增加错误处理难度。
fileSize 为什么不能防止 ZIP 炸弹?req、res?res.download() 回调后才能删除临时 ZIP?error 事件?res.headersSent === true 后发生错误,为什么不能再返回 JSON?