文件与富文本处理
文件相关能力的实践手册:覆盖上传方式与校验、大文件分片与断点续传、秒传实现、图片压缩与水印、对象存储接入、Excel 导入导出、PDF 生成、富文本编辑与安全过滤,并给出每类场景的安全要点与常见坑。
一、文件上传基础
1.1 三种上传方式
| 方式 | 请求形式 | 适用 | 缺点 |
|---|---|---|---|
| 表单上传 | multipart/form-data |
常规文件、图片 | 大文件一次传输,失败需整体重传 |
| Base64 | JSON 里放 base64 字符串 | 小图(头像裁剪后) | 体积膨胀约 33% |
| 分片上传 | 多次小块请求 | 大文件、弱网 | 实现复杂 |
// multipart/form-data 前端const form = new FormData()form.append('file', fileInput.files[0])await fetch('/api/upload', { method: 'POST', body: form })// 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)
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 校验四层(缺一不可)
// 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 存储目录与命名
// ✅ 重命名:不用用户原始文件名(防路径穿越、防覆盖)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 前端分片实现
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 服务端分片接收与合并
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)
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 两种上传模式
| 模式 | 流程 | 适用 |
|---|---|---|
| 服务端中转 | 前端 → 自己的服务器 → 对象存储 | 小文件、需要服务端处理(压缩/审核) |
| 前端直传(推荐) | 服务端签发临时凭证 → 前端直传对象存储 | 大文件、高并发,省服务器带宽 |
// 前端直传:服务端只负责签发凭证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
// 私有文件:生成带有效期的访问地址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 下载接口要点
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 断点续传下载(大文件)
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)
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 导入(关键在容错)
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 | 代码绘制 | 结构固定的报表、发票 |
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 安全过滤(必须做两次)
// 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: 协议})// 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:协议 - 粘贴内容经过滤,图片自动上传
十二、总结
文件能力的技术难点,集中在四件事:
- 校验要分层——扩展名、MIME、魔数缺一不可,因为前两者都能伪造;图片再走一次重新编码,顺手解决图片马与 EXIF 隐私问题。
- 大文件靠分片——分片 + 断点续传 + 秒传是标配;实现时最容易踩的是「合并时把整个文件读进内存」,务必用流。
- 存储尽量外置——对象存储 + 前端直传能显著降低服务器带宽与磁盘压力;签发临时凭证时务必限制路径与大小。
- 富文本必须净化两次——服务端入库前是信任边界,前端渲染前是纵深防御;只做一次都不够。
最后一条实践建议:把上传校验与分片逻辑封装成公共模块。这类代码在多个项目里几乎一模一样,每次重写一遍既浪费时间,又容易漏掉某一层校验。
完