全栈手账 订阅 RSS

文件与富文本处理

文件相关能力的实践手册:覆盖上传方式与校验、大文件分片与断点续传、秒传实现、图片压缩与水印、对象存储接入、Excel 导入导出、PDF 生成、富文本编辑与安全过滤,并给出每类场景的安全要点与常见坑。

读了 31 分钟 更新于 11 月 3 日

一、文件上传基础

1.1 三种上传方式

方式 请求形式 适用 缺点
表单上传 multipart/form-data 常规文件、图片 大文件一次传输,失败需整体重传
Base64 JSON 里放 base64 字符串 小图(头像裁剪后) 体积膨胀约 33%
分片上传 多次小块请求 大文件、弱网 实现复杂
javascript
// multipart/form-data 前端const form = new FormData()form.append('file', fileInput.files[0])await fetch('/api/upload', { method: 'POST', body: form })
javascript
// Base64 方式(仅适合 < 200KB 的小图)const reader = new FileReader()reader.onload = () => {  const base64 = reader.result.split(',')[1]  fetch('/api/avatar', {    method: 'POST',    headers: { 'Content-Type': 'application/json' },    body: JSON.stringify({ data: base64 }),  })}reader.readAsDataURL(file)

1.2 服务端接收(Node + multer)

javascript
const multer = require('@koa/multer')const upload = multer({  storage: multer.memoryStorage(),          // 或 diskStorage 直接落盘  limits: {    fileSize: 10 * 1024 * 1024,             // 单文件 10MB    files: 5,                                // 最多 5 个文件  },})router.post('/upload', upload.single('file'), async (ctx) => {  if (!ctx.file) ctx.throw(400, '未选择文件')  // ... 校验与存储})

1.3 校验四层(缺一不可)

javascript
// 1. 扩展名白名单const ALLOWED_EXT = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.pdf'])const ext = path.extname(file.originalname).toLowerCase()if (!ALLOWED_EXT.has(ext)) throw new Error('不支持的文件类型')// 2. MIME 类型if (!['image/jpeg', 'image/png', 'application/pdf'].includes(file.mimetype)) {  throw new Error('类型不匹配')}// 3. 文件头魔数(最关键,扩展名与 MIME 都可伪造)const MAGIC = [  { sig: Buffer.from([0xff, 0xd8, 0xff]), ext: '.jpg' },  { sig: Buffer.from([0x89, 0x50, 0x4e, 0x47]), ext: '.png' },  { sig: Buffer.from([0x47, 0x49, 0x46]), ext: '.gif' },  { sig: Buffer.from([0x25, 0x50, 0x44, 0x46]), ext: '.pdf' },   // %PDF]const header = file.buffer.subarray(0, 8)if (!MAGIC.some((m) => header.subarray(0, m.sig.length).equals(m.sig))) {  throw new Error('文件内容与类型不符')}// 4. 大小限制(multer limits 已限制,服务层再校验一次兜底)if (file.size > 10 * 1024 * 1024) throw new Error('文件过大')

1.4 存储目录与命名

javascript
// ✅ 重命名:不用用户原始文件名(防路径穿越、防覆盖)const filename = `${Date.now()}_${crypto.randomUUID()}${ext}`// ✅ 按日期分目录,避免单目录文件过多const dir = `/data/uploads/${year}/${month}/${day}/`// ✅ 保存原始文件名到数据库(用于下载时还原)await fileRepo.save({ path: `${dir}${filename}`, originalName: file.originalname })

存储位置建议:

位置 适用 注意
本地磁盘 小项目、私有部署 需备份;多实例不可共享
对象存储(OSS/COS/S3) 推荐 天然 CDN、免运维、可扩展
数据库 BLOB ❌ 不推荐 撑大数据库、备份恢复困难

二、大文件上传

2.1 三个核心能力

能力 价值
分片 单次请求体积可控,失败只重传一片
断点续传 中断后可续,不必从头再来
秒传 文件已存在则直接返回地址,零传输

2.2 流程设计

文本
1. 前端计算文件 hash(可用抽样 hash 加速大文件)2. 请求 POST /api/upload/check { hash, name, size }   → 已存在:直接返回 url(秒传)   → 不存在:返回已上传的分片序号列表(断点续传)3. 前端按 chunkSize 切片,逐片上传   POST /api/upload/chunk { hash, index, chunk }4. 全部上传完成后:POST /api/upload/merge { hash, total, name }   → 服务端按序合并,校验最终 hash,返回 url5. 清理临时分片

2.3 前端分片实现

javascript
const CHUNK_SIZE = 2 * 1024 * 1024   // 2MBasync function uploadLargeFile(file) {  const hash = await calcHash(file)          // 分片 hash 合并计算,不要一次读全文件  // 1. 检查是否秒传  const check = await api.post('/upload/check', {    hash, name: file.name, size: file.size,  })  if (check.uploaded) return check.url       // 秒传  // 2. 计算待上传分片(跳过已上传的)  const total = Math.ceil(file.size / CHUNK_SIZE)  const uploaded = new Set(check.uploadedChunks)  const tasks = []  for (let i = 0; i < total; i++) {    if (uploaded.has(i)) continue    const chunk = file.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE)    tasks.push(      uploadChunkWithRetry({ hash, index: i, chunk }, 3)   // 失败重试 3 次    )  }  await Promise.all(tasks)                   // 并发上传(建议限并发 3~5)  // 3. 合并  return api.post('/upload/merge', { hash, total, name: file.name })}async function uploadChunkWithRetry(data, retries) {  for (let i = 0; i < retries; i++) {    try {      return await api.post('/upload/chunk', toFormData(data))    } catch (e) {      if (i === retries - 1) throw e      await sleep(1000 * (i + 1))            // 退避重试    }  }}

2.4 服务端分片接收与合并

javascript
const TMP_DIR = '/data/tmp/chunks'// 接收分片router.post('/upload/chunk', upload.single('chunk'), async (ctx) => {  const { hash, index } = ctx.request.body  const dir = path.join(TMP_DIR, hash)  fs.mkdirSync(dir, { recursive: true })  fs.writeFileSync(path.join(dir, String(index)), ctx.file.buffer)  ctx.body = { code: 0 }})// 合并router.post('/upload/merge', async (ctx) => {  const { hash, total, name } = ctx.request.body  const dir = path.join(TMP_DIR, hash)  // 校验分片齐全  const exist = fs.readdirSync(dir).length  if (exist !== Number(total)) ctx.throw(400, `分片缺失:${exist}/${total}`)  // 按序合并(用流,避免一次性读入内存)  const target = path.join(UPLOAD_DIR, `${hash}${path.extname(name)}`)  const writeStream = fs.createWriteStream(target)  for (let i = 0; i < total; i++) {    const buf = fs.readFileSync(path.join(dir, String(i)))    writeStream.write(buf)  }  writeStream.end()  await new Promise((r) => writeStream.on('finish', r))  fs.rmSync(dir, { recursive: true, force: true })    // 清理临时分片  ctx.body = { code: 0, data: { url: toPublicUrl(target) } }})

2.5 关键注意点

点 说明
hash 计算不要阻塞主线程 大文件用 Web Worker;或改用抽样 hash(首尾各 2MB)
合并要用流 一次性 Buffer.concat 会把整个文件读进内存,大文件直接 OOM
校验最终 hash 合并后计算整体 hash 与客户端一致,否则说明分片损坏
临时分片要过期清理 定时任务删除 24 小时前的临时目录
并发限制 同时上传 3–5 片,过多会打满带宽与连接数

三、图片处理

3.1 常见需求

需求 做法
压缩 按最长边缩放 + 质量 75–85
缩略图 生成多种尺寸(列表用 200px,详情用 800px)
格式转换 统一转 WebP(体积小 25%–35%)
水印 文字/图片叠加,注意位置与透明度
方向纠正 读取 EXIF 的 Orientation 并旋转

3.2 Node 实现(sharp)

javascript
const sharp = require('sharp')async function processImage(input, outputDir, basename) {  const base = sharp(input).rotate()          // rotate() 无参数 = 按 EXIF 自动纠正方向  // 生成多尺寸  const sizes = [    { name: 'thumb', width: 200, quality: 75 },    { name: 'medium', width: 800, quality: 82 },    { name: 'large', width: 1600, quality: 85 },  ]  const results = {}  for (const s of sizes) {    const out = `${outputDir}/${basename}_${s.name}.webp`    await base.clone()      .resize({ width: s.width, withoutEnlargement: true })   // 不放大小图      .webp({ quality: s.quality })      .toFile(out)    results[s.name] = out  }  // 记录元信息  const meta = await sharp(input).metadata()  return { results, width: meta.width, height: meta.height }}

3.3 上传即压缩,还是访问时压缩?

方案 优点 缺点
上传时生成多规格(推荐) 访问快,CDN 友好 存储成本增加
访问时动态压缩 存储省 每次消耗 CPU;需缓存
交给 CDN 的图片处理 零代码 依赖服务商能力与费用

一般做法:上传时生成 2–3 个规格,列表用小的、详情用中的、原图保留(或按策略丢弃)。

3.4 安全注意

风险 防护
图片马(图片中嵌入恶意脚本) 重新编码(sharp 处理即天然剥离),不要原样存储
超大像素图导致内存耗尽 限制输入像素数:sharp(input, { limitInputPixels: 1e8 })
SVG 携带脚本 单独处理:净化后再存储,或以 Content-Disposition: attachment 下载
EXIF 泄露隐私 处理后默认会丢失 EXIF(含 GPS 信息),这是好事

四、对象存储接入

4.1 为什么用对象存储

优势 说明
免运维 不需关心磁盘容量与备份
天然 CDN 就近分发,加载快
弹性 容量无上限
私有读 支持签名 URL 临时授权

4.2 两种上传模式

模式 流程 适用
服务端中转 前端 → 自己的服务器 → 对象存储 小文件、需要服务端处理(压缩/审核)
前端直传(推荐) 服务端签发临时凭证 → 前端直传对象存储 大文件、高并发,省服务器带宽
javascript
// 前端直传:服务端只负责签发凭证router.get('/upload/token', async (ctx) => {  const policy = {    expiration: new Date(Date.now() + 10 * 60 * 1000).toISOString(),    conditions: [      ['content-length-range', 0, 20 * 1024 * 1024],   // 限制大小      ['starts-with', '$key', 'uploads/'],             // 限制路径    ],  }  ctx.body = {    code: 0,    data: {      host: process.env.OSS_HOST,      policy: base64(JSON.stringify(policy)),      signature: sign(policy, process.env.OSS_SECRET),      accessKeyId: process.env.OSS_ACCESS_KEY,      dir: 'uploads/',    },  }})

关键:临时凭证必须限制路径、大小、有效期,否则等于开放了整个存储桶的写权限。

4.3 私有读与签名 URL

javascript
// 私有文件:生成带有效期的访问地址function signedUrl(key, expiresInSec = 3600) {  const expires = Math.floor(Date.now() / 1000) + expiresInSec  const signature = hmac(`${method}\n${expires}\n/${bucket}/${key}`, secret)  return `https://${host}/${key}?expires=${expires}&signature=${signature}`}

五、文件下载与权限

5.1 下载接口要点

javascript
router.get('/files/:id/download', auth, async (ctx) => {  const file = await fileRepo.findById(ctx.params.id)  // 1. 权限校验:不是所有登录用户都能下所有文件  if (file.ownerId !== ctx.state.user.id && !ctx.state.user.isAdmin) {    ctx.throw(404)                                    // 用 404 不泄露存在性  }  // 2. 中文文件名要做编码  const encoded = encodeURIComponent(file.originalName)  ctx.set('Content-Disposition', `attachment; filename*=UTF-8''${encoded}`)  ctx.set('Content-Type', 'application/octet-stream')  // 3. 用流返回,避免大文件进内存  ctx.body = fs.createReadStream(file.path)})

5.2 断点续传下载(大文件)

javascript
const { size } = fs.statSync(filePath)const range = ctx.headers.rangeif (range) {  const [startStr, endStr] = range.replace('bytes=', '').split('-')  const start = parseInt(startStr, 10)  const end = endStr ? parseInt(endStr, 10) : size - 1  ctx.status = 206  ctx.set('Content-Range', `bytes ${start}-${end}/${size}`)  ctx.set('Accept-Ranges', 'bytes')  ctx.set('Content-Length', String(end - start + 1))  ctx.body = fs.createReadStream(filePath, { start, end })} else {  ctx.set('Accept-Ranges', 'bytes')  ctx.set('Content-Length', String(size))  ctx.body = fs.createReadStream(filePath)}

支持 Range 后,浏览器与下载工具可以断点续传;视频也可拖动进度条。


六、Excel 导入导出

6.1 导出(Node + exceljs)

javascript
const ExcelJS = require('exceljs')async function exportOrders(ctx) {  const workbook = new ExcelJS.Workbook()  const sheet = workbook.addWorksheet('订单列表')  sheet.columns = [    { header: '订单号', key: 'orderNo', width: 24 },    { header: '金额', key: 'amount', width: 12 },    { header: '状态', key: 'status', width: 10 },    { header: '创建时间', key: 'createdAt', width: 22 },  ]  // 分批写入,避免一次性加载全量数据  let page = 1  const pageSize = 1000  while (true) {    const rows = await orderRepo.find({ skip: (page - 1) * pageSize, take: pageSize })    if (!rows.length) break    rows.forEach((r) => sheet.addRow({      orderNo: r.orderNo,      amount: r.amount / 100,      status: r.status,      createdAt: r.createdAt.toISOString(),    }))    page++  }  // 流式输出,不落盘也不占内存  ctx.set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')  ctx.set('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent('订单列表.xlsx')}`)  ctx.body = workbook.xlsx.writeBuffer()}

大数据量导出:超过 5 万行建议改为异步任务——创建任务 → 后台生成 → 通知下载,避免请求超时。

6.2 导入(关键在容错)

javascript
const ExcelJS = require('exceljs')async function importUsers(buffer) {  const workbook = new ExcelJS.Workbook()  await workbook.xlsx.load(buffer)  const sheet = workbook.worksheets[0]  const errors = []  const valid = []  sheet.eachRow((row, rowNumber) => {    if (rowNumber === 1) return              // 跳过表头    const [name, phone, email] = row.values.slice(1)    // 逐行校验,收集所有错误而不是遇到第一个就中断    const rowErrors = []    if (!name) rowErrors.push('姓名不能为空')    if (!/^1\d{10}$/.test(String(phone))) rowErrors.push('手机号格式错误')    if (rowErrors.length) {      errors.push({ row: rowNumber, message: rowErrors.join(';') })    } else {      valid.push({ name, phone, email })    }  })  // 有效数据批量入库(分批 500 条)  for (let i = 0; i < valid.length; i += 500) {    await userRepo.insertMany(valid.slice(i, i + 500))  }  return { total: sheet.rowCount - 1, success: valid.length, errors }}

导入设计要点:

要点 说明
收集全部错误 不要遇到第一个错误就中断,用户需要一次看到所有问题
返回错误明细 带行号与原因,便于用户修正
限制行数 如最多 5000 行,超出提示分批导入
提供模板下载 固定表头与格式,减少格式错误
事务/分批 全量失败回滚,或分批提交并记录进度
防重复导入 用业务唯一键去重

七、PDF 生成

方案 说明 适用
HTML 转 PDF(Puppeteer) 用模板渲染 HTML 再打印 复杂排版、复用前端样式
PDFKit / pdfmake 代码绘制 结构固定的报表、发票
javascript
const puppeteer = require('puppeteer')async function htmlToPdf(html) {  const browser = await puppeteer.launch({ args: ['--no-sandbox'] })  const page = await browser.newPage()  await page.setContent(html, { waitUntil: 'networkidle0' })  const pdf = await page.pdf({    format: 'A4',    printBackground: true,    margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },  })  await browser.close()  return pdf}

注意:Puppeteer 启动浏览器实例开销较大(几十到上百 MB 内存)。高并发场景应复用实例或用独立的渲染服务,不要每个请求都启动一次。


八、富文本编辑与安全

8.1 编辑器选型

编辑器 特点
TipTap 基于 ProseMirror,可定制性强,现代推荐
Quill 轻量、成熟
wangEditor 中文生态好,开箱即用

8.2 内容存储格式

格式 优点 缺点
HTML 通用、易渲染 需净化,防止 XSS
Markdown 安全、体积小 表达能力有限
JSON(结构化) 最安全、可精确渲染 需自行渲染实现

8.3 安全过滤(必须做两次)

javascript
// 1. 服务端入库前净化(信任边界在服务端)const DOMPurify = require('isomorphic-dompurify')const clean = DOMPurify.sanitize(rawHtml, {  ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 's', 'h1', 'h2', 'h3',                 'ul', 'ol', 'li', 'blockquote', 'code', 'pre',                 'a', 'img', 'table', 'thead', 'tbody', 'tr', 'th', 'td'],  ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class'],  ALLOWED_URI_REGEXP: /^(?:https?|mailto):/i,     // 禁止 javascript: 协议})
javascript
// 2. 前端渲染前再净化一次(纵深防御)el.innerHTML = DOMPurify.sanitize(content)

8.4 富文本的其他坑

坑 说明
粘贴携带外部样式 配置编辑器的 paste 过滤,只保留纯文本或白名单样式
图片粘贴为 base64 体积巨大,应自动上传为 URL
相对路径链接 渲染时需转换为绝对地址
表格/代码块样式丢失 前端渲染时需引入对应 CSS
字数统计不准 HTML 里的标签不应计入,需按纯文本统计

九、安全要点汇总

风险 防护
上传可执行文件 扩展名白名单 + 魔数校验 + 存储目录禁止执行脚本
路径穿越(../../etc/passwd) 存储时重命名;读取时用 ID 查库取路径,不用用户输入拼路径
覆盖他人文件 文件名加 UUID,目录按用户或日期隔离
图片马 / XSS 图片重新编码;SVG 净化或强制下载
越权下载 下载接口校验资源归属
存储桶公开 私有读写 + 签名 URL + 临时凭证限制路径与大小
大文件耗尽资源 限制大小;合并用流;限制并发

十、常见问题

现象 原因 处理
大文件上传失败 Nginx/网关的 body 限制 调大 client_max_body_size
上传超时 请求超时过短 分片上传;调大超时
图片方向错乱 未处理 EXIF Orientation sharp().rotate() 无参自动纠正
中文文件名乱码 未做 URL 编码 Content-Disposition 用 filename*=UTF-8'' + encodeURIComponent
合并失败内存溢出 一次性读全文件 用流合并
Excel 导出超时 同步生成大数据量 超过 5 万行改异步任务
导入部分失败无提示 未收集错误明细 逐行校验并返回行号与原因
上传后页面仍显示旧图 浏览器缓存同名文件 文件名带 hash/UUID,或用版本查询参数

十一、检查清单

上传

  • 扩展名白名单 + MIME + 魔数三层校验
  • 大小限制在服务端与网关两处都配置
  • 存储文件名重写(UUID),保留原始名入库
  • 上传目录禁止执行脚本
  • 按日期或用户分目录,避免单目录文件过多

大文件

  • 支持分片、断点续传
  • hash 计算不阻塞主线程(Worker 或抽样)
  • 合并使用流,不整文件读入内存
  • 最终校验整体 hash
  • 临时分片有定时清理

图片

  • 上传时生成多规格 + 转 WebP
  • 限制输入像素数,防内存耗尽
  • 重新编码(剥离潜在的恶意内容与 EXIF 隐私)

对象存储

  • 大文件走前端直传,服务端只签发凭证
  • 临时凭证限制路径、大小、有效期
  • 私有文件用签名 URL,不公开整桶

下载与导出

  • 下载接口校验资源归属
  • 中文文件名正确编码
  • 大文件用流 + 支持 Range
  • 超大数据量导出改异步任务
  • Excel 导入收集全部错误并返回行号

富文本

  • 服务端入库前净化(白名单标签与属性)
  • 前端渲染前再净化一次
  • 禁止 javascript: 协议
  • 粘贴内容经过滤,图片自动上传

十二、总结

文件能力的技术难点,集中在四件事:

  1. 校验要分层——扩展名、MIME、魔数缺一不可,因为前两者都能伪造;图片再走一次重新编码,顺手解决图片马与 EXIF 隐私问题。
  2. 大文件靠分片——分片 + 断点续传 + 秒传是标配;实现时最容易踩的是「合并时把整个文件读进内存」,务必用流。
  3. 存储尽量外置——对象存储 + 前端直传能显著降低服务器带宽与磁盘压力;签发临时凭证时务必限制路径与大小。
  4. 富文本必须净化两次——服务端入库前是信任边界,前端渲染前是纵深防御;只做一次都不够。

最后一条实践建议:把上传校验与分片逻辑封装成公共模块。这类代码在多个项目里几乎一模一样,每次重写一遍既浪费时间,又容易漏掉某一层校验。


完

前端、后端、部署、客服都是我一个人。写这里的东西都是真踩过的坑,不是抄来的教程。